diff --git a/.github/actions/analyze-obs/merge-clang-sarif/README.md b/.github/actions/analyze-obs/merge-clang-sarif/README.md new file mode 100644 index 00000000000000..cc28942224bc3f --- /dev/null +++ b/.github/actions/analyze-obs/merge-clang-sarif/README.md @@ -0,0 +1,86 @@ +# merge-clang-sarif Action + +The merge-clang-sarif action combines multiple SARIF files as typically generated by Clang Static Analyzer into a single merged SARIF file as required by CodeQL. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|:-------:| +| `path` | The path on the runner to a directory with SARIF files that need to be merged. | `REQUIRED` | +| `output-name` | An optional output name for the merged SARIF file. | `merged.sarif` | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `path` | The path to the generated merged SARIF file using the specified `output-name`. | + +## Common Usage + +The action requires generated SARIF files to be collected into a single location for merging, so a preparation step might be necessary before invoking the action itself: + +```yaml + - name: Gather All SARIF Files + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ github.workspace }} + run: | + mkdir -p "${RUNNER_TEMP}/codeql" + find . -type f -name '*.sarif' | xargs -I '{}' mv '{}' "${RUNNER_TEMP}/codeql" +``` + +This location can then be passed to the action directly: + +```yaml + - name: Merge SARIF files + uses: ./.github/actions/merge-clang-sarif + with: + path: ${{ format('{0}/codeql', runner.temp) }} + output-name: my-merged-report.sarif +``` + +## Notes + +* The action runs no verification on the SARIF files found in the provided input `path`. Any file with the `.sarif` suffix will be used as input for merging. An invalid SARIF file can thus lead to making the action fail. +* The provided input `path` needs to exist on the runner's file system and needs to be a directory. + +## Developer Notes + +* The merging of files is necessary because CodeQL requires all reports to originate from a single run of the associated "tool", but Clang Static Analyzer produces reports for each translation unit, which represents individual runs of the tool. +* The action is designed and tested for SARIF files generated by `AppleClang` 21.0+. + +The actual merging of files is done entirely by `jq` using the following query: + +```typescript +{ + // All SARIF files generated by Clang share the same schema and version object, so simply use the + // first such element. + "$schema": first(.[]."$schema"), + "version": first(.[].version), + "runs": [{ + "tool": { + // The driver object is almost identical in all files apart from the "rules" object. + // So reuse the driver object of the first such element, but remove the "rules" key. + // The add a new "rules" key and collect all rules from all driver objects in all files + // and ensure the rules collection only contains unique elements. + "driver": (first(.[].runs[].tool.driver) | del(.rules)) + { + "rules": reduce(.[].runs[].tool.driver.rules) as $obj ([]; . + $obj) | unique + } + }, + // Gather all artifacts elements into a single unique collection. + "artifacts": reduce(.[].runs[].artifacts) as $obj ([]; . + $obj) | unique, + // Gather all results elements into a single unique collection, but also remove any + // "endLine" and "endColumn" keys deep in the "codeFlows" collection whose value is + // a zero, which is not allowed by SARIF. + "results": (reduce(.[].runs[].results) as $obj ([]; . + $obj)) + | del( + .[].codeFlows[].threadFlows[].locations[].location.physicalLocation.region.endLine, + .[].codeFlows[].threadFlows[].locations[].location.physicalLocation.region.endColumn + | select(. == 0) + ) + }] +} +``` + +* File paths are handled in their UNIX variant and converted from and to Windows format at the input/output edge of the Bash script. diff --git a/.github/actions/analyze-obs/merge-clang-sarif/action.yaml b/.github/actions/analyze-obs/merge-clang-sarif/action.yaml new file mode 100644 index 00000000000000..40fa4d6ecf6b4e --- /dev/null +++ b/.github/actions/analyze-obs/merge-clang-sarif/action.yaml @@ -0,0 +1,23 @@ +name: Merge Clang SARIF Files +description: Merges SARIF files generated by Clang Static Analyzer into a single tool run as required by CodeQL. +inputs: + path: + description: The path on the runner to a directory with SARIF files that need to be merged. + required: true + output-name: + description: An optional output name for the merged SARIF file. + default: 'merged.sarif' +outputs: + path: + description: The path to the generated merged SARIF file using the specified output-name. + value: ${{ steps.merge.outputs.path }} +runs: + using: composite + steps: + - name: Merge SARIF Files + id: merge + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + SARIF_PATH: ${{ inputs.path }} + OUTPUT_NAME: ${{ inputs.output-name }} + run: ${GITHUB_ACTION_PATH}/merge-sarif.bash diff --git a/.github/actions/analyze-obs/merge-clang-sarif/merge-sarif.bash b/.github/actions/analyze-obs/merge-clang-sarif/merge-sarif.bash new file mode 100755 index 00000000000000..c3d128523dea3a --- /dev/null +++ b/.github/actions/analyze-obs/merge-clang-sarif/merge-sarif.bash @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +merge-sarif() { + if [[ ! -d "${SARIF_PATH}" ]]; then + echo "::error::Provided path for SARIF files '${SARIF_PATH}' is not a directory." + return 1 + fi + + if [[ "${RUNNER_OS}" == 'Windows' ]]; then + SARIF_PATH="$(cygpath --unix "${SARIF_PATH}")" + fi + + local output + local -a sarif_files=() + if output="$(compgen -G "${SARIF_PATH}/*.sarif")"; then + while read -r file; do + full_path="$(realpath "${file}")" + sarif_files+=("${full_path}") + done <<< "${output}" + fi + + if (( ! ${#sarif_files[@]} )); then + echo "::error::No SARIF files found in '${SARIF_PATH}'." + return 1 + fi + + local output_path="${RUNNER_TEMP}/${OUTPUT_NAME}" + jq --slurp '{ + "$schema": first(.[]."$schema"), + "version": first(.[].version), + "runs": [{ + "tool": { + "driver": (first(.[].runs[].tool.driver) | del(.rules)) + { + "rules": reduce(.[].runs[].tool.driver.rules) as $obj ([]; . + $obj) | unique + } + }, + "artifacts": reduce(.[].runs[].artifacts) as $obj ([]; . + $obj) | unique, + "results": (reduce(.[].runs[].results) as $obj ([]; . + $obj)) + | del( + .[].codeFlows[].threadFlows[].locations[].location.physicalLocation.region.endLine, + .[].codeFlows[].threadFlows[].locations[].location.physicalLocation.region.endColumn + | select(. == 0) + ) + }] + }' "${sarif_files[@]}" > "${output_path}" + + if [[ "${RUNNER_OS}" == 'Windows' ]]; then + output_path="$(cygpath --windows "${output_path}")" + fi + echo "path=${output_path}" >> "${GITHUB_OUTPUT}" +} + +merge-sarif diff --git a/.github/actions/analyze-obs/run-clang-analyze/README.md b/.github/actions/analyze-obs/run-clang-analyze/README.md new file mode 100644 index 00000000000000..a97fc23a54a8b8 --- /dev/null +++ b/.github/actions/analyze-obs/run-clang-analyze/README.md @@ -0,0 +1,47 @@ +# run-clang-analyze Action + +The run-clang-analyze action uses Clang Static Analyzer to generate SARIF files which can then be uploaded to GitHub as a CodeQL report after merging the files into a format accepted by GitHub. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|:-------:| +| `architecture` | The CPU architecture used for building OBS Studio. Available values are `x86_64` and `arm64`.| `REQUIRED` | +| `upload-codeql` | A boolean value to indicate whether the generated SARIF report should be automatically uploaded. |`false`| +| `xcode-version` | An Xcode version number to select a specific Xcode version preinstalled on the runner. | `''` | +| `github-token`| The GitHub token required to upload the SARIF file as CodeQL report. The provided token needs to have the `security-events: write` permission. | `github.token`| + +### Outputs + +The action has no outputs. + +## Common Usage + +The action requires no prior setup and can be invoked directly on a checkout of the repository: + +```yaml + - name: Analyze OBS Studio + id: analyze + uses: ./.github/actions/analyze-obs/run-clang-analyze + with: + architecture: arm64 + upload-codeql: true + +``` + +Be aware that just like the `build-obs` action, environment variables can influence project generation by CMake and code paths might not be included in the build and analysis without them. Thus it should be ensured that a "maximalist" build of OBS Studio can be configured to achieve the highest possible coverage of source code. + +## Notes + +> [!IMPORTANT] +> The action requires a macOS or Linux GitHub Actions runner. + +* The CodeQL report uses the category identifier `clang-analyze`. + +## Developer Notes + +Under the hood the action uses the `build-obs` action with the optional input `analyze` set to `true`. The action thus mostly serves as a convenience wrapper around this action, with the benefit of automatically gathering and merging of all generated SARIF files so that their contents can be (optionally) uploaded as a single CodeQL report. + +The merging is handled by the `merge-clang-sarif` action, refer to its documentation for details about the production of a single CodeQL report file. diff --git a/.github/actions/analyze-obs/run-clang-analyze/action.yaml b/.github/actions/analyze-obs/run-clang-analyze/action.yaml new file mode 100644 index 00000000000000..8b78fd5dc05eea --- /dev/null +++ b/.github/actions/analyze-obs/run-clang-analyze/action.yaml @@ -0,0 +1,62 @@ +name: Run clang-analyze +description: > + Uses Clang Static Analyzer to generate SARIF files which can then be uploaded to GitHub as a CodeQL report after + merging the files into a format accepted by GitHub. +inputs: + architecture: + description: > + The CPU architecture used for building OBS Studio. Available values are 'x86_64' and 'arm64'. + required: true + upload-codeql: + description: A boolean value to indicate whether the generated SARIF report should be automatically uploaded. + default: 'false' + xcode-version: + description: An Xcode version number to select a specific Xcode version pre-installed on the runner. + default: '' + github-token: + description: > + The GitHub token required to upload the SARIF file as CodeQL report. + The provided token needs to have the 'security-events: write' permission. + default: ${{ github.token }} +runs: + using: composite + steps: + - name: Check Runner + uses: ./.github/actions/check-runner + with: + os: | + macOS + Linux + custom-error: analyze-obs/run-clang-analyze action requires a macOS or Linux runner. + + - name: Build OBS Studio + id: build + uses: ./.github/actions/build-obs + with: + config: Debug + architecture: ${{ inputs.architecture }} + analyze: true + xcode-version: ${{ inputs.xcode-version }} + + - name: Gather Xcode SARIF files + if: runner.os == 'macOS' + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.working-directory }} + env: + ANALYTICS_PATH: ${{ steps.build.outputs.analyzer-output-path }} + run: zsh --errexit --pipefail --no-rcs ${GITHUB_ACTION_PATH}/gather-xcode-sarif-files.zsh + + - name: Merge SARIF files + id: merge + uses: ./.github/actions/analyze-obs/merge-clang-sarif + with: + path: ${{ steps.build.outputs.analyzer-output-path }} + output-name: ${{ format('obs-studio-{0}-{1}.sarif', runner.os, inputs.architecture) }} + + - name: Upload CodeQL Report + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + if: ${{ fromJSON(inputs.upload-codeql) }} + with: + token: ${{ inputs.github-token }} + sarif_file: ${{ steps.merge.outputs.path }} + category: ${{ format('clang-analyze ({0} {1}', runner.os, inputs.architecture) }} diff --git a/.github/actions/analyze-obs/run-clang-analyze/gather-xcode-sarif-files.zsh b/.github/actions/analyze-obs/run-clang-analyze/gather-xcode-sarif-files.zsh new file mode 100755 index 00000000000000..9defff03eb5753 --- /dev/null +++ b/.github/actions/analyze-obs/run-clang-analyze/gather-xcode-sarif-files.zsh @@ -0,0 +1,28 @@ +#!/usr/bin/env zsh + +builtin emulate -L zsh +setopt PUSHD_SILENT +setopt EXTENDED_GLOB +setopt ERR_EXIT +setopt ERR_RETURN +setopt NO_UNSET +setopt PIPE_FAIL +setopt NO_AUTO_PUSHD +setopt NO_PUSHD_IGNORE_DUPS +setopt NO_GLOB_SUBST +setopt WARN_CREATE_GLOBAL +setopt WARN_NESTED_VAR + +: ${CI:?} +if (( ${+RUNNER_DEBUG} )) setopt XTRACE + +gather-xcode-sarif-files() { + local -a analytics_files=(${ANALYTICS_PATH}/StaticAnalyzer/obs-studio/**/*.plist) + + local file + for file (${analytics_files}) { + mv ${file} ${ANALYTICS_PATH}/${${file:t}//plist/sarif} + } +} + +gather-xcode-sarif-files diff --git a/.github/actions/analyze-obs/run-pvs-studio/README.md b/.github/actions/analyze-obs/run-pvs-studio/README.md new file mode 100644 index 00000000000000..d03dbe1ebefb9f --- /dev/null +++ b/.github/actions/analyze-obs/run-pvs-studio/README.md @@ -0,0 +1,48 @@ +# run-pvs-studio Action + +The run-pvs-studio action uses PVS-Studio to run static code analysis on an existing OBS Studio Visual Studio project and uploads the generated SARIF file as a CodeQL report. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|:-------:| +| `architecture` | The CPU architecture used for building OBS Studio Available values are `x64` and `arm64`. | `REQUIRED` | +| `upload-codeql` | A boolean value to indicate whether the generated SARIF report should be automatically uploaded. |`false`| +| `github-token`| The GitHub token required to upload the SARIF file as CodeQL report. The provided token needs to have the `security-events: write` permission. | `github.token`| + +### Outputs + +The action has no outputs. + +## Common Usage + +The action requires no prior setup and can be invoked directly on a checkout of the repository: + +```yaml + - name: Analyze OBS Studio + id: analyze + uses: ./.github/actions/analyze-obs/run-pvs-studio + with: + architecture: x64 + upload-codeql: true + +``` + +Be aware that just like the `build-obs` action, environment variables can influence project generation by CMake and code paths might not be included in the build and analysis without them. Thus it should be ensured that a "maximalist" build of OBS Studio can be configured to achieve the highest possible coverage of source code. + +## Notes + +> [!IMPORTANT] +> The action requires a Windows GitHub Actions runner. + +* The action does not do any setup or installation of PVS Studio, this needs to be done in preparation before calling the action. +* The generated PVS-Studio log is uploaded as a workflow artifact by default. The CodeQL report is only optionally uploaded. + +## Developer Notes + +Under the hood the action uses the `build-obs` action to generate a Visual Studio project and builds the project once before passing the generated solution file to PVS Studio. As Microsoft changed the file extension of Visual Studio solution files with Visual Studio 19 2026 to `slnx` the action tries to pick up (and pass along) either variant inside the automatically selected directory path. + +* PVS-Studio is capable of converting its own report into a single SARIF tool report by itself, so no merging or fix-up is necessary. +* File paths are handled in their UNIX variant and converted from and to Windows format at the input/output edge of the Bash script. diff --git a/.github/actions/analyze-obs/run-pvs-studio/action.yaml b/.github/actions/analyze-obs/run-pvs-studio/action.yaml new file mode 100644 index 00000000000000..3e410d9a52cab7 --- /dev/null +++ b/.github/actions/analyze-obs/run-pvs-studio/action.yaml @@ -0,0 +1,58 @@ +name: Run PVS-Studio +description: > + Uses PVS-Studio to run static code analysis on an existing OBS Studio Visual Studio project and uploads the generated + SARIF file as a CodeQL report. +inputs: + architecture: + description: > + The CPU architecture used for building OBS Studio. + Available values are 'x64' and 'arm64'. + required: true + upload-codeql: + description: A boolean value to indicate whether the generated SARIF report should be automatically uploaded. + default: 'false' + github-token: + description: > + The GitHub token required to upload the SARIF file as CodeQL report. + The provided token needs to have the 'security-events: write' permission. + default: ${{ github.token }} +runs: + using: composite + steps: + - name: Check Runner + uses: ./.github/actions/check-runner + with: + os: Windows + custom-error: analyze-obs/run-pvs-studio action requires a Windows runner. + + - name: Build OBS Studio + id: build + uses: ./.github/actions/build-obs + with: + config: Debug + architecture: ${{ inputs.architecture }} + path: ${{ runner.temp }} + + - name: Run PVS-Studio + id: analyze + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + BUILD_SOLUTION: ${{ format('{0}/build_{1}/obs-studio.sln?', runner.temp, inputs.architecture) }} + BUILD_ARCHITECTURE: ${{ inputs.architecture }} + BUILD_CONFIG: Debug + run: ${GITHUB_ACTION_PATH}/run-pvs-studio.bash + + - name: Upload PVS-Studio Output + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pvs-analysis-log + path: | + ${{ format('{0}/pvs-analysis.plog', runner.temp) }} + ${{ format('{0}/pvs-analysis.plog.sarif', runner.temp) }} + + - name: Upload PVS-Studio CodeQL Report + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + if: ${{ fromJSON(inputs.upload-codeql) }} + with: + sarif_file: ${{ format('{0}/pvs-analysis.plog.sarif', runner.temp) }} + category: PVS-Studio (Windows) diff --git a/.github/actions/windows-analysis/obs.pvsconfig b/.github/actions/analyze-obs/run-pvs-studio/obs.pvsconfig similarity index 100% rename from .github/actions/windows-analysis/obs.pvsconfig rename to .github/actions/analyze-obs/run-pvs-studio/obs.pvsconfig diff --git a/.github/actions/analyze-obs/run-pvs-studio/run-pvs-studio.bash b/.github/actions/analyze-obs/run-pvs-studio/run-pvs-studio.bash new file mode 100755 index 00000000000000..703d787ba6b984 --- /dev/null +++ b/.github/actions/analyze-obs/run-pvs-studio/run-pvs-studio.bash @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +convert-to-sarif() { + local -a pvs_conversion_arguments=( + --analyzer 'GA:1,2' + --excludedCodes 'V1042,Renew' + --renderTypes 'Sarif' + --outputDir "${RUNNER_TEMP}" + ) + + local pvs_converter_location + pvs_converter_location="$(cygpath --unix "${PROGRAMFILES} (x86)/PVS-Studio/PlogConverter.exe")" + + "${pvs_converter_location}" "${pvs_conversion_arguments[@]}" "${RUNNER_TEMP}/pvs-analysis.plog" + + local expected_sarif_file="${RUNNER_TEMP}/pvs-analysis.plog.sarif" + if [[ ! -r "${expected_sarif_file}" ]]; then + echo "::error::Generated SARIF file '${expected_sarif_file}' not found." + return 1 + fi +} + +run-pvs-studio() { + BUILD_SOLUTION="$(cygpath --unix "${BUILD_SOLUTION}")" + + local output + if ! output="$(compgen -G "${BUILD_SOLUTION}")"; then + echo "::error::Unable to find Visual Studio build solution via '${BUILD_SOLUTION}'." + return 1 + fi + + local -a pvs_arguments=( + --progress + --disableLicenseExpirationCheck + --platform "${BUILD_ARCHITECTURE}" + --configuration "${BUILD_CONFIG}" + --target "${output}" + --output "${RUNNER_TEMP}/pvs-analysis.plog" + --rulesConfig "${GITHUB_ACTION_PATH}/obs.pvsconfig" + ) + + local pvs_location + pvs_location="$(cygpath --unix "${PROGRAMFILES} (x86)/PVS-Studio/PVS-Studio_Cmd.exe")" + + # Acceptable error codes per https://pvs-studio.com/en/docs/manual/0035/ + if ! "${pvs_location}" "${pvs_arguments[@]}"; then + local -i accepted_code_mask="$(( 1024 | 256 ))" + + local -i return_code="${?}" + + if ! (( return_code & accepted_code_mask )); then + echo "::error::PVS-Studio exited with unsupported error code ${return_code}." + return 1 + fi + fi + + convert-to-sarif +} + +run-pvs-studio diff --git a/.github/actions/analyze-obs/setup-pvs-studio/README.md b/.github/actions/analyze-obs/setup-pvs-studio/README.md new file mode 100644 index 00000000000000..c29d0c352d3c7f --- /dev/null +++ b/.github/actions/analyze-obs/setup-pvs-studio/README.md @@ -0,0 +1,59 @@ +# setup-pvs-studio Action + +The setup-pvs-studio action downloads the specified version of PVS Studio from the provided download location and installs it on the Windows-based runner. + +PVS Studio requires a valid license key to work, whose details need to be provided the the action. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `versione` | The PVS Studio version to install.| `REQUIRED`| +| `checksum` | The SHA-256 checksum of the downloaded PVS Studio installer. | `REQUIRED`| +| `url`| The URL of the PVS Studio setup program required for installation. | (Default Download URL) [^1] | +| `user` | The PVS Studio license user name. | `REQUIRED`| +| `license` | The PVS Studio license key. | `REQUIRED`| + +[^1]: https://files.pvs-studio.com/PVS-Studio_setup.exe + +### Outputs + +The action has no outputs. + +## Common Usage + +The action will not discover potential PVS Studio metadata present in the project. This information has to be parsed independently before invoking the action. + +```yaml + - name: Create Action Inputs + id: pvs-studio-data + run: | + { + echo "version=4.5" + echo "checksum=123" + echo "user=" + echo "license=" + } >> "${GITHUB_OUTPUT}" + + - name: Set Up PVS Studio + id: analyze + uses: ./.github/actions/analyze-obs/setup-pvs-studio + with: + version: ${{ steps.pvs-studio-data.outputs.version }} + checksum: ${{ steps.pvs-studio-data.outputs.checksum }} + user: ${{ steps.pvs-studio-data.outputs.user }} + license: ${{ steps.pvs-studio-data.outputs.license }} +``` + +## Notes + +> [!IMPORTANT] +> The action requires a Windows GitHub Actions runner. + +## Developer Notes + +Even though the action accepts a `url` input to explicitly specify a download URL, this URL needs to match the current default download URL very closely, as this URL is the only known canonical download location of PVS Studio. The regular expression implemented in the Powershell script would thus need to be changed to allow different or updated download URLs. + +* File paths are handled in their UNIX variant and converted from and to Windows format at the input/output edge of the Bash script. diff --git a/.github/actions/analyze-obs/setup-pvs-studio/action.yaml b/.github/actions/analyze-obs/setup-pvs-studio/action.yaml new file mode 100644 index 00000000000000..a0c45758c97e42 --- /dev/null +++ b/.github/actions/analyze-obs/setup-pvs-studio/action.yaml @@ -0,0 +1,44 @@ +name: Setup PVS-Studio +description: > + Downloads the specified version of PVS Studio from the provided download location and installs it on the Windows + runner. + + PVS Studio requires a valid license key to work, whose details need to be provided the the action. +inputs: + version: + description: The PVS Studio version to install. + required: true + checksum: + description: The SHA-256 checksum of the downloaded PVS Studio installer. + required: true + url: + description: The URL of the PVS Studio setup program required for installation. + default: 'https://files.pvs-studio.com/PVS-Studio_setup.exe' + user: + description: The PVS Studio license user name. + required: true + license: + description: The PVS Studio license key. + required: true +runs: + using: composite + steps: + - name: Check Runner Operating System + if: runner.os != 'Windows' + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + run: | + : Check Runner Operating System + echo '::error::analyze-obs/setup-pvs-studio action requires a Windows runner.' + exit 1 + + - name: Setup PVS-Studio + id: setup + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.workingDirectory }} + env: + PVS_STUDIO_URL: ${{ inputs.url }} + PVS_STUDIO_VERSION: ${{ inputs.version }} + PVS_STUDIO_CHECKSUM: ${{ inputs.checksum }} + PVS_STUDIO_USERNAME: ${{ inputs.user }} + PVS_STUDIO_LICENSE: ${{ inputs.license }} + run: ${GITHUB_ACTION_PATH}/setup-pvs-studio.bash diff --git a/.github/actions/analyze-obs/setup-pvs-studio/setup-pvs-studio.bash b/.github/actions/analyze-obs/setup-pvs-studio/setup-pvs-studio.bash new file mode 100755 index 00000000000000..26d481405f3620 --- /dev/null +++ b/.github/actions/analyze-obs/setup-pvs-studio/setup-pvs-studio.bash @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +setup-pvs-studio() { + if [[ -z "${PVS_STUDIO_USERNAME}" || -z "${PVS_STUDIO_LICENSE}" ]]; then + echo "::error::PVS-Studio setup requires a license username and license key." + return 1 + fi + + local pvs_studio_regex='^https:\/\/files\.pvs-studio\.com/PVS-Studio_setup.exe$' + if ! [[ "${PVS_STUDIO_URL}" =~ ${pvs_studio_regex} ]]; then + echo "::error::Invalid PVS-Studio download url '${PVS_STUDIO_URL}'." + return 1 + fi + + echo "::group::Download PVS-Studio ${PVS_STUDIO_VERSION}" + curl \ + --location \ + --remote-name \ + --output-dir "${RUNNER_TEMP}" \ + -- "${PVS_STUDIO_URL}" + + local pvs_file_basename + pvs_file_basename="$(basename "${PVS_STUDIO_URL}")" + + local shasum_result + shasum_result="$(openssl dgst -sha256 "${RUNNER_TEMP}/${pvs_file_basename}")" + local checksum + read -r _ checksum <<< "${shasum_result}" + + if [[ "${PVS_STUDIO_CHECKSUM,,*}" != "${checksum}" ]]; then + echo "::error::${pvs_file_basename} checksum mismatch: ${checksum} (expected: ${PVS_STUDIO_CHECKSUM,,*})." + return 1 + fi + echo '::endgroup::' + + echo '::group::Install PVS-Studio' + local -a pvs_install_arguments=( + "//components=Core" + "//silent" + "//supressmsgboxes" + "//norestart" + "//nocloseapplications" + "//skipNetFrameworkInstallation" + ) + + "${RUNNER_TEMP}/${pvs_file_basename}" "${pvs_install_arguments[@]}" + echo '::endgroup::' + + echo '::group::Activate PVS-Studio' + local -a pvs_activate_arguments=( + credentials --userName "${PVS_STUDIO_USERNAME}" --licenseKey "${PVS_STUDIO_LICENSE}" + ) + local pvs_location + pvs_location="$(cygpath --unix "${PROGRAMFILES} (x86)/PVS-Studio/PVS-Studio_Cmd.exe")" + "${pvs_location}" "${pvs_activate_arguments[@]}" + echo '::endgroup::' + +} + +setup-pvs-studio diff --git a/.github/actions/build-flatpak/README.md b/.github/actions/build-flatpak/README.md new file mode 100644 index 00000000000000..e2d06548fd8a83 --- /dev/null +++ b/.github/actions/build-flatpak/README.md @@ -0,0 +1,43 @@ +# build-flatpak Action + +The build-flatpak action bundles a set of common steps for building a Flatpak bundle using the manifest available in the OBS Studio repository. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `architecture` | The CPU architecture to build OBS-Studio for. Available value is `x86_64`. | `REQUIRED` | +| `bundle` | A boolean value to indicate whether to actually build a bundle. If set to `false`, an available cached bundle is used instead. | `false` | +| `github-token` | The GitHub token required to check GitHub Actions caches for available cached bundles. | `github.token`| +| `working-directory` | The path to a directory with an OBS Studio checkout for the action to operate on. | `github.workspace` | + +### Outputs + +The action has no outputs. + +## Common Usage + +The action will attempt to detect if a compatible GitHub Actions cache entry is available for re-use and will then either download an available cache or create a new one based off the result of the bundle operation. + +```yaml + - name: Create Flatpak Bundle + uses: ./.github/actions/build-flatpak + with: + architecture: x86_64 + bundle: true +``` + +## Notes + +> [!IMPORTANT] +> The action requires a Linux GitHub Actions runner. + +* The tool-chain required to build Flatpak manifests is commonly preinstalled on containers available by the Flathub organization for use on GitHub Actions. + +## Developer Notes + +The action makes use of https://github.com/flatpak/flatpak-github-actions to do the actual bundling. This action is also ultimately responsible for restoring or creating the GitHub Actions cache entry. + +For convenience reasons, the action has been designed to re-use the default cache key used by the action, which currently uses the `flatpak-builder--<20-character-SHA>` pattern. Notably, the `architecture` token is second-to-last when used in the automatically generated cache key, but is automatically _appended_ as the last token to any manually provided cache key (potentially duplicating this information if already provided as part of the key). diff --git a/.github/actions/build-flatpak/action.yaml b/.github/actions/build-flatpak/action.yaml new file mode 100644 index 00000000000000..cb9a14c0ddcf3d --- /dev/null +++ b/.github/actions/build-flatpak/action.yaml @@ -0,0 +1,101 @@ +name: Build Flatpak +description: Builds OBS Studio for Flatpak. +inputs: + architecture: + description: The CPU architecture to build OBS-Studio for. Available value is 'x86_64'. + required: true + bundle: + description: > + A boolean value to indicate whether to actually build a bundle. + If set to 'false', an available cached bundle is used instead. + default: 'false' + builder-branch: + description: Branch used by flatpak-builder to export application. + default: 'master' + github-token: + description: The GitHub token required to check GitHub Actions caches for available cached bundles. + default: ${{ github.token }} + working-directory: + description: The path to a directory with an OBS Studio checkout for the action to operate on. + default: ${{ github.workspace }} +runs: + using: composite + steps: + - name: Check Runner Operating System + if: runner.os != 'Linux' + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + run: | + : Check Runner Operating System + echo '::error::build-flatpak action requires a Linux runner.' + exit 1 + + - name: Check Flatpak Builder Cache + id: cache + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.working-directory }} + env: + GH_TOKEN: ${{ inputs.github-token }} + run: ${GITHUB_ACTION_PATH}/check-builder-cache.bash + + - name: Validate Flatpak manifest + uses: ./.github/actions/lint-obs/lint-flatpak-manifest + with: + artifact: manifest + path: build-aux/com.obsproject.Studio.json + + - name: Generate Short GitHub SHA + id: short-sha + uses: ./.github/actions/generate-short-sha + + - name: Build Flatpak Manifest + uses: flatpak/flatpak-github-actions/flatpak-builder@401fe28a8384095fc1531b9d320b292f0ee45adb # v6.7 + with: + build-bundle: ${{ fromJSON(inputs.bundle) }} + bundle: ${{ format('obs-studio-flatpak-{0}.flatpak', steps.short-sha.outputs.sha) }} + branch: ${{ inputs.builder-branch }} + cache: >- + ${{ fromJSON(steps.cache.outputs.cache-hit) + || (github.event_name == 'push' && github.ref_name == 'master') + }} + manifest-path: ${{ format('{0}/build-aux/com.obsproject.Studio.json', github.workspace) }} + mirror-screenshots-url: https://dl.flathub.org/media + restore-cache: >- + ${{ + inputs.builder-branch != 'master' + && fromJSON(steps.cache.outputs.cache-hit) + }} + + - name: Validate AppStream + uses: ./.github/actions/lint-obs/lint-flatpak-manifest + with: + artifact: appstream + path: ${{ format('{0}/metainfo/com.obsproject.Studio.metainfo.xml', env.FLATPAK_BUILD_SHARE_PATH) }} + + - name: Verify Icon and Metadata in app-info + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ env.FLATPAK_BUILD_SHARE_PATH }} + run: | + : Verify Icon and Metadata in app-info + if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + + if [[ ! -f "${PWD}/app-info/icons/flatpak/128x128/com.obsproject.Studio.png" ]]; then + echo '::error::Missing 128x128 icon in app-info' + exit 1 + fi + + if [[ ! -f "${PWD}/app-info/xmls/com.obsproject.Studio.xml.gz" ]]; then + echo '::error::Missing com.obsproject.Studio.xml.gz in app-info' + exit 1 + fi + + - name: Validate build directory + uses: ./.github/actions/lint-obs/lint-flatpak-manifest + with: + artifact: builddir + path: flatpak_app + + - name: Validate repository + uses: ./.github/actions/lint-obs/lint-flatpak-manifest + with: + artifact: repo + path: repo diff --git a/.github/actions/build-flatpak/check-builder-cache.bash b/.github/actions/build-flatpak/check-builder-cache.bash new file mode 100755 index 00000000000000..73b938bfb6c953 --- /dev/null +++ b/.github/actions/build-flatpak/check-builder-cache.bash @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +check-builder-cache() { + local checksum + checksum="$(openssl dgst -sha256 "${PWD}/build-aux/com.obsproject.Studio.json")" + + local manifest_hash + read -r _ manifest_hash <<< "${checksum}" + + # When provided a cache key, Flatpak-builder automatically adds the + # build architecture to the key in case multiple architectures are built. + # Otherwise it generates a cache-key based on the pattern: + # 'flatpak-builder-- + cache_key="$(gh cache list \ + --ref "refs/heads/master" \ + --key "flatpak-builder-x86_64-${manifest_hash:0:20}" \ + --limit 1 --json key --jq '.[0].key')" + + { + if [[ -n "${cache_key}" ]]; then + echo "cache-hit=true" + else + echo "cache-hit=false" + fi + } >> "${GITHUB_OUTPUT}" +} + +check-builder-cache diff --git a/.github/actions/build-obs/README.md b/.github/actions/build-obs/README.md new file mode 100644 index 00000000000000..a5a1b813d8df61 --- /dev/null +++ b/.github/actions/build-obs/README.md @@ -0,0 +1,67 @@ +# build-obs Action + +The build-obs action runs a standardized build of OBS Studio for the platform of the GitHub Actions runner it is called on. It uses dedicated CI presets present in the project's `CMakePresets.json` file to inherit the same build settings as local builds, with compile warnings elevated to errors by default. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `config` | The CMake-style build configuration to use for building OBS Studio. See Notes. | `RelWithDebInfo` | +| `architecture` | The CPU architecture to build OBS-Studio for. See Notes. | `REQUIRED` | +| `codesign-ident` | The Apple Developer ID to use for code signing on macOS. | `-` | +| `codesign-team` | The Apple Developer team ID to use for code signing on macOS. | `''` | +| `provisioning-profile-id` | The UUID of the provisioning profile to use for macOS builds. See Notes. | `''` | +| `analyze` | A boolean value to indicate whether to use a Clang `analyze` build. | `false` | +| `path` | The parent path for the destination directory of the generated build system. | `runner.temp` | +| `xcode-version` | An Xcode version number to select a specific Xcode version preinstalled on the runner. | `''` | +| `xcode-cas-path` | The path to an Xcode compilation cache. | `''` | +| `working-directory` | The path to a directory with an OBS Studio checkout for the action to operate on. | `github.workspace` | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `analyzer-output-path` | The path to the directory with generated SARIF output files. | + +## Common Usage + +While the action will use the GitHub Actions runner operating system for platform selection, it will not automatically select an architecture, which needs to be provided as a required input. + +```yaml + - name: Build OBS Studio + id: build + uses: ./.github/actions/build-obs + env: + SOME_BUILD_VARIABLE: 'ON' + with: + config: RelWithDebInfo + target: arm64 + codesign-ident: 'My Developer Name ()' + codesign-team: '' + provisioning-profile-id: '' + xcode-version: 26.6 + xcode-cas-path: ${{ format('{0}/Compilation-Cache.noindex', runner.temp) }} +``` + +## Notes + +Some inputs have validity constraints, not all of which are enforced immediately (instead the action permits the underlying build system generation or compilation to fail): + +* `config` needs to be a valid CMake build configuration, so either `Debug`, `RelWithDebInfo`, `Release`, or `MinSizeRel`. +* `architecture` needs to be either `x86_64` or `arm64`. + * `x86_64` will be automatically changed to `x64` for Windows builds. + * `arm64` is only fully supported on macOS and Windows. Using it for builds on Linux GitHub Actions runners will lead to undefined behavior. +* If `xcode-version` is provided, the version needs to be installed on the GitHub Actions runner. The action will fail early if this condition is not met. +* Compilation caches are neither restored nor saved automatically by the action. + +## Developer Notes + +The action effectively serves as an automatic launcher of `CMake` to create a build system appropriate for each supported platform (Xcode on macOS, Visual Studio on Windows, Ninja on Ubuntu) and also runs compilation of the project. + +* On macOS the specified Xcode version and location of the compilation cache are set up automatically before creation of the build system. +* On Ubuntu all dependencies required for build system generation as well as all dependencies for building OBS Studio are automatically installed using `apt-get`. +* `Ccache` is used to speed up Ubuntu builds on GitHub Actions if a preexisting cache had been restored. + * The expected location of the compilation cache for OBS Studio builds is a directory named `.ccache` in the GitHub Actions runner's `temp` directory. +* File paths are handled in their UNIX variant and converted from and to Windows format at the input/output edge of the Bash script. diff --git a/.github/actions/build-obs/action.yaml b/.github/actions/build-obs/action.yaml index ddafb124b32ee3..b836bf22d824a1 100644 --- a/.github/actions/build-obs/action.yaml +++ b/.github/actions/build-obs/action.yaml @@ -1,121 +1,107 @@ -name: Set Up and Build obs-studio -description: Builds obs-studio for specified architecture and build config +name: Build OBS Studio +description: > + Runs a standardized build of OBS Studio for the platform of the runner it is called on. It uses dedicated CI presets + present in the project's 'CMakePresets.json' file to inherit the same build settings as local builds, with compile + warnings elevated to errors by default. inputs: - target: - description: Build target for obs-studio - required: true config: - description: Build configuration - required: false + description: > + The CMake-style build configuration to use for building OBS Studio. + Available values are 'Debug', 'RelWithDebInfo', 'Release', and 'MinSizeRel'. default: RelWithDebInfo - codesign: - description: Enable codesigning (macOS only) - required: false - default: 'false' - codesignIdent: - description: Developer ID for application codesigning (macOS only) - required: false + architecture: + description: > + The CPU architecture to build OBS-Studio for. + Available values are 'arm64' and 'x86_64'. + required: true + codesign-ident: + description: The Apple Developer ID to use for code signing on macOS. default: '-' - codesignTeam: - description: Team ID for application codesigning (macOS only) - required: false + codesign-team: + description: The Apple Developer team ID to use for code signing on macOS. + default: '' + provisioning-profile-id: + description: The UUID of the provisioning profile to use for macOS builds. + default: '' + analyze: + description: A boolean value indicating whether to use a Clang 'analyze' build. + default: 'false' + path: + description: The parent path for the destination directory of the generated build system. + default: ${{ runner.temp }} + xcode-version: + description: An Xcode version number to select a specific Xcode version pre-installed on the runner. default: '' - provisioningProfileUUID: - description: UUID of provisioning profile (macOS only) - required: false + xcode-cas-path: + description: The path to an Xcode compilation cache. default: '' - workingDirectory: - description: Working directory for packaging - required: false + working-directory: + description: The path to a directory with an OBS Studio checkout for the action to operate on. default: ${{ github.workspace }} +outputs: + analyzer-output-path: + description: The path to the directory with generated SARIF output files. + value: >- + ${{ case( + runner.os == 'macOS', steps.build-macos.outputs.analyzer-output-path, + runner.os == 'Linux', steps.build-ubuntu.outputs.analyzer-output-path, + null + ) }} runs: using: composite steps: - - name: Run macOS Build + - name: Set Up Xcode + id: setup-macos if: runner.os == 'macOS' - shell: zsh --no-rcs --errexit --pipefail {0} - working-directory: ${{ inputs.workingDirectory }} + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} env: - CODESIGN_IDENT: ${{ inputs.codesignIdent }} - CODESIGN_TEAM: ${{ inputs.codesignTeam }} - PROVISIONING_PROFILE: ${{ inputs.provisioningProfileUUID }} - run: | - : Run macOS Build - - local -a build_args=( - --config ${{ inputs.config }} - --target macos-${{ inputs.target }} - ) - if (( ${+RUNNER_DEBUG} )) build_args+=(--debug) - - if [[ '${{ inputs.codesign }}' == true ]] build_args+=(--codesign) + XCODE_VERSION: ${{ inputs.xcode-version }} + XCODE_CAS_PATH: ${{ inputs.xcode-cas-path }} + run: zsh --errexit --pipefail --no-rcs ${GITHUB_ACTION_PATH}/setup-macos.zsh - git fetch origin --no-tags --no-recurse-submodules -q - .github/scripts/build-macos ${build_args} + - name: Run macOS Build + id: build-macos + if: runner.os == 'macOS' + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.working-directory }} + env: + BUILD_CONFIG: ${{ inputs.config }} + BUILD_TARGET: ${{ inputs.architecture }} + CODESIGN_IDENT: ${{ inputs.codesign-ident }} + CODESIGN_TEAM: ${{ inputs.codesign-team }} + PROVISIONING_PROFILE: ${{ inputs.provisioning-profile-id }} + OUTPUT_PATH: ${{ inputs.path }} + ANALYZE: ${{ inputs.analyze }} + XCODE_CAS_PATH: ${{ steps.setup-macos.outputs.xcode-cas-path }} + run: zsh --errexit --pipefail --no-rcs ${GITHUB_ACTION_PATH}/build-macos.zsh - - name: Install Dependencies đŸ›ī¸ + - name: Install Ubuntu Build Dependencies + id: setup-ubuntu if: runner.os == 'Linux' - shell: bash - run: | - : Install Dependencies đŸ›ī¸ - echo ::group::Install Dependencies - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" - echo "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin" >> $GITHUB_PATH - brew update - brew install --quiet zsh - echo ::endgroup:: + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + BUILD_TARGET: ${{ inputs.architecture }} + OUTPUT_PATH: ${{ inputs.path }} + run: ${GITHUB_ACTION_PATH}/setup-ubuntu.bash - name: Run Ubuntu Build + id: build-ubuntu if: runner.os == 'Linux' - shell: zsh --no-rcs --errexit --pipefail {0} - working-directory: ${{ inputs.workingDirectory }} - run: | - : Run Ubuntu Build - - local -a build_args=( - --config ${{ inputs.config }} - --target ubuntu-${{ inputs.target }} - ) - if (( ${+RUNNER_DEBUG} )) build_args+=(--debug) - - git fetch origin --no-tags --no-recurse-submodules -q - .github/scripts/build-ubuntu ${build_args} + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + BUILD_CONFIG: ${{ inputs.config }} + BUILD_TARGET: ${{ inputs.architecture }} + OUTPUT_PATH: ${{ inputs.path }} + working-directory: ${{ inputs.working-directory }} + run: ${GITHUB_ACTION_PATH}/build-ubuntu.bash - name: Run Windows Build + id: build-windows if: runner.os == 'Windows' - shell: pwsh - working-directory: ${{ inputs.workingDirectory }} - run: | - # Run Windows Build - $BuildArgs = @{ - Target = '${{ inputs.target }}' - Configuration = '${{ inputs.config }}' - } - - if ( $Env:RUNNER_DEBUG -ne $null ) { - $BuildArgs += @{ Debug = $true } - } - - git fetch origin --no-tags --no-recurse-submodules -q - .github/scripts/Build-Windows.ps1 @BuildArgs - - - name: Create Summary 📊 - if: runner.os == 'Linux' - shell: zsh --no-rcs --errexit --pipefail {0} - run: | - : Create Summary 📊 - - local -a ccache_data - if (( ${+RUNNER_DEBUG} )) { - setopt XTRACE - ccache_data=("${(fA)$(ccache -s -vv)}") - } else { - ccache_data=("${(fA)$(ccache -s)}") - } - - print '### ${{ runner.os }} Ccache Stats (${{ inputs.target }})' >> $GITHUB_STEP_SUMMARY - print '```' >> $GITHUB_STEP_SUMMARY - for line (${ccache_data}) { - print ${line} >> $GITHUB_STEP_SUMMARY - } - print '```' >> $GITHUB_STEP_SUMMARY + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + BUILD_CONFIG: ${{ inputs.config }} + BUILD_TARGET: ${{ case(inputs.architecture == 'x86_64', 'x64', inputs.architecture) }} + OUTPUT_PATH: ${{ inputs.path }} + working-directory: ${{ inputs.working-directory }} + run: ${GITHUB_ACTION_PATH}/build-windows.bash diff --git a/.github/actions/build-obs/build-macos.zsh b/.github/actions/build-obs/build-macos.zsh new file mode 100755 index 00000000000000..c746302fedbb46 --- /dev/null +++ b/.github/actions/build-obs/build-macos.zsh @@ -0,0 +1,137 @@ +#!/usr/bin/env zsh + +builtin emulate -L zsh +setopt PUSHD_SILENT +setopt EXTENDED_GLOB +setopt ERR_EXIT +setopt ERR_RETURN +setopt NO_UNSET +setopt PIPE_FAIL +setopt NO_AUTO_PUSHD +setopt NO_PUSHD_IGNORE_DUPS +setopt NO_GLOB_SUBST +setopt WARN_CREATE_GLOBAL +setopt WARN_NESTED_VAR + +: ${CI:?} +if (( ${+RUNNER_DEBUG} )) setopt XTRACE + +run_xcodebuild() { + if (( ${+RUNNER_DEBUG} )) || [[ ${ANALYZE:-false} == 'true' ]] { + env NSUnbufferedIO=YES xcodebuild ${@} 2>&1 | xcbeautify --preserve-unbeautified --renderer terminal + } else { + env NSUnbufferedIO=YES xcodebuild ${@} 2>&1 | xcbeautify --renderer github-actions + } +} + +build-macos() { + local checkout="${PWD}" + + if ! [[ -d ${checkout}/.git && -r ${checkout}/CMakePresets.json ]] { + print '::error::Action needs to be run from the root directory of an obs-studio checkout.' + return 1 + } + + if (( ${+RUNNER_DEBUG} )) { + cmake --version + } + + typeset -gx CODESIGN_IDENT + typeset -gx CODESIGN_TEAM + + mkdir -p ${OUTPUT_PATH} + + local build_dir + { + local preset_build_dir + preset_build_dir="$(jq --raw-output ' + .configurePresets[] | select(.name == "macos") | .binaryDir + ' ${checkout}/CMakePresets.json)" + build_dir="${preset_build_dir//\$\{sourceDir\}/${OUTPUT_PATH}}" + } + + if (( ! ${+CODESIGN_TEAM} )) { + CODESIGN_TEAM="${${CODESIGN_IDENT##*\(}%%\)*}" + print "::add-mask::${CODESIGN_TEAM}" + } + + print '::group::Configure obs-studio' + local -a cmake_args=( + --preset macos-ci + -B ${build_dir} + "-DCMAKE_OSX_ARCHITECTURES:STRING=${BUILD_TARGET}" + ) + + if (( ${+RUNNER_DEBUG} )) { + cmake_args+=( + --log-level=DEBUG + -DCMAKE_XCODE_ATTRIBUTE_COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS:STRING=YES + ) + } + + cmake ${cmake_args} + print '::endgroup::' + + print '::group::Build obs-studio' + if [[ ! -d ${build_dir} ]] { + print "::echo::Expected build directory '${build_dir}' not found." + return 1 + } + + pushd ${build_dir} + + local -a common_args=( + ONLY_ACTIVE_ARCH=NO -project obs-studio.xcodeproj + -destination 'generic/platform=macOS,name=Any Mac' + -parallelizeTargets -hideShellScriptEnvironment + ) + + if [[ ${ANALYZE:-false} == 'true' ]] { + local -a analyze_args=( + CLANG_ANALYZER_OUTPUT=sarif + CLANG_ANALYZER_OUTPUT_DIR=${build_dir}/analytics + ${common_args} + -target obs-studio + -configuration ${BUILD_CONFIG} + analyze + ) + + run_xcodebuild ${analyze_args} + + print "analyzer-output-path=${build_dir}/analytics" >> ${GITHUB_OUTPUT} + } else { + local match + local mbegin + local mend + local version_regex='[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta).+)?' + if [[ "${GITHUB_EVENT_NAME:-}" == 'push' && "${GITHUB_REF_NAME:-}" =~ ${version_regex} && -n ${CODESIGN_TEAM} ]] { + common_args+=(-scheme obs-studio -archivePath obs-studio.xcarchive archive) + + local -a export_args=( + -exportArchive -archivePath obs-studio.xcarchive -exportOptionsPlist exportOptions.plist + -exportPath ${OUTPUT_PATH} + ) + + run_xcodebuild ${common_args} + run_xcodebuild ${export_args} + } else { + common_args+=(-scheme obs-studio -configuration "${BUILD_CONFIG}" build) + + run_xcodebuild ${common_args} + + local app_bundle="${build_dir}/frontend/${BUILD_CONFIG}/OBS.app" + + if [[ ! -d ${app_bundle} ]] { + print "::error::Expected application bundle '${app_bundle}' not found." + return 1 + } + + mkdir ${OUTPUT_PATH}/OBS.app + ditto ${app_bundle} ${OUTPUT_PATH}/OBS.app + } + } + popd + print '::endgroup::' +} + +build-macos diff --git a/.github/actions/build-obs/build-ubuntu.bash b/.github/actions/build-obs/build-ubuntu.bash new file mode 100755 index 00000000000000..1bf9872474f64a --- /dev/null +++ b/.github/actions/build-obs/build-ubuntu.bash @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +build-ubuntu() { + local checkout="${PWD}" + if ! [[ -d "${checkout}/.git" && -r "${checkout}/CMakePresets.json" ]]; then + echo '::error::Action needs to be run from the root directory of an obs-studio checkout.' + return 1 + fi + + if [[ -n "${RUNNER_DEBUG:-}" ]]; then + cmake --version + fi + + mkdir -p "${OUTPUT_PATH}" + + local build_dir + { + local preset_build_dir + preset_build_dir="$(jq --raw-output ' + .configurePresets[] | select(.name == "ubuntu") | .binaryDir + ' "${checkout}/CMakePresets.json")" + build_dir="${preset_build_dir//\$\{sourceDir\}/"${OUTPUT_PATH}"}" + } + + declare -x CLICOLOR_FORCE=1 + + echo '::group::Configure obs-studio' + local -a cmake_args=( + --preset ubuntu-ci + -B "${build_dir}" + -DENABLE_BROWSER:BOOL=ON + -DCEF_ROOT_DIR:PATH="${build_dir}/.deps/cef_binary_${CEF_VERSION}_linux_${BUILD_TARGET}" + ) + + if [[ -n "${RUNNER_DEBUG:-}" ]]; then + cmake_args+=(--log-level=DEBUG) + fi + + /usr/bin/cmake "${cmake_args[@]}" + echo '::endgroup::' + + echo '::group::Build obs-studio' + local -a cmake_build_args=( + --build "${build_dir}" + --config "${BUILD_CONFIG}" + --parallel + ) + + if [[ -n "${RUNNER_DEBUG:-}" ]]; then + cmake_build_args+=(--verbose); + fi + + /usr/bin/cmake "${cmake_build_args[@]}" + echo '::endgroup::' + + echo '::group::CCache Statistics' + if [[ -n "${RUNNER_DEBUG:-}" ]]; then + ccache --show-stats --verbose --verbose + else + ccache --show-stats + fi + echo '::endgroup::' +} + +build-ubuntu diff --git a/.github/actions/build-obs/build-windows.bash b/.github/actions/build-obs/build-windows.bash new file mode 100755 index 00000000000000..22fd7fc5307d3f --- /dev/null +++ b/.github/actions/build-obs/build-windows.bash @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +build-windows() { + local checkout="${PWD}" + if ! [[ -d "${checkout}/.git" && -r "${checkout}/CMakePresets.json" ]]; then + echo '::error::Action needs to be run from the root directory of an obs-studio checkout.' + return 1 + fi + + if [[ -n "${RUNNER_DEBUG:-}" ]]; then + cmake --version + fi + + mkdir -p "${OUTPUT_PATH}" + + local build_dir + { + local preset_build_dir + preset_build_dir="$(jq --raw-output --arg platform "windows-${BUILD_TARGET}" ' + .configurePresets[] | select(.name == $platform) | .binaryDir + ' "${checkout}/CMakePresets.json")" + build_dir="${preset_build_dir//\$\{sourceDir\}/"${OUTPUT_PATH}"}" + } + + echo '::group::Configure obs-studio' + local -a cmake_args=( + --preset "windows-ci-${BUILD_TARGET}" + -B "${build_dir}" + ) + + if [[ -n "${RUNNER_DEBUG:-}" ]]; then + cmake_args+=(--log-level=DEBUG) + fi + + cmake "${cmake_args[@]}" + echo '::endgroup::' + + echo '::group::Build obs-studio' + local -a cmake_build_args=( + --build "${build_dir}" + --config "${BUILD_CONFIG}" + --parallel + ) + + if [[ -n "${RUNNER_DEBUG:-}" ]]; then + cmake_build_args+=(--verbose); + fi + + cmake "${cmake_build_args[@]}" -- //consoleLoggerParameters:Summary //nologo + echo '::endgroup::' +} + +build-windows diff --git a/.github/actions/build-obs/setup-macos.zsh b/.github/actions/build-obs/setup-macos.zsh new file mode 100755 index 00000000000000..a1a09face6faaa --- /dev/null +++ b/.github/actions/build-obs/setup-macos.zsh @@ -0,0 +1,49 @@ +#!/usr/bin/env zsh + +builtin emulate -L zsh +setopt PUSHD_SILENT +setopt EXTENDED_GLOB +setopt ERR_EXIT +setopt ERR_RETURN +setopt NO_UNSET +setopt PIPE_FAIL +setopt NO_AUTO_PUSHD +setopt NO_PUSHD_IGNORE_DUPS +setopt NO_GLOB_SUBST +setopt WARN_CREATE_GLOBAL +setopt WARN_NESTED_VAR + +: ${CI:?} +if (( ${+RUNNER_DEBUG} )) setopt XTRACE + +switch-xcode-version() { + if [[ -n ${XCODE_VERSION:-} ]] { + if [[ ${XCODE_VERSION} == <->##.<-> ]] { + if [[ -d /Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer ]] { + sudo xcode-select --switch /Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer + } else { + print "::error::Xcode version ${XCODE_VERSION} not found on runner." + return 1 + } + } else { + print "::error::Provided invalid version value ${XCODE_VERSION}." + return 1 + } + } else { + sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer + } +} + +setup-macos() { + switch-xcode-version + + local cache_path="${XCODE_CAS_PATH:-"${HOME}/Library/Developer/Xcode/DerivedData/CompilationCache.noindex"}" + + if [[ ! -d ${cache_path} ]] { + mkdir -p ${cache_path} + } + + print "xcode-cas-path=${cache_path}" >> ${GITHUB_OUTPUT} +} + +setup-macos diff --git a/.github/actions/build-obs/setup-ubuntu.bash b/.github/actions/build-obs/setup-ubuntu.bash new file mode 100755 index 00000000000000..9984e043982767 --- /dev/null +++ b/.github/actions/build-obs/setup-ubuntu.bash @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +setup-system-packages() { + echo '::group::Install System Packages' + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + cmake ccache build-essential libglib2.0-dev \ + extra-cmake-modules lsb-release dh-cmake \ + libcurl4-openssl-dev \ + libavcodec-dev libavdevice-dev libavfilter-dev libavformat-dev libavutil-dev \ + libswresample-dev libswscale-dev \ + libjansson-dev \ + libx11-xcb-dev \ + libgles2-mesa-dev \ + libwayland-dev \ + libpipewire-0.3-dev \ + libpulse-dev \ + libx264-dev \ + libmbedtls-dev \ + libgl1-mesa-dev \ + libjansson-dev \ + uthash-dev \ + libsimde-dev \ + libluajit-5.1-dev python3-dev \ + libx11-dev libxcb-randr0-dev libxcb-shm0-dev libxcb-xinerama0-dev \ + libxcb-composite0-dev libxinerama-dev libxcb1-dev libx11-xcb-dev libxcb-xfixes0-dev \ + swig libcmocka-dev libxss-dev libglvnd-dev \ + libxkbcommon-dev libatk1.0-dev libatk-bridge2.0-dev libxcomposite-dev libxdamage-dev \ + libasound2-dev libfdk-aac-dev libfontconfig-dev libfreetype6-dev libjack-jackd2-dev \ + libpulse-dev libsndio-dev libspeexdsp-dev libudev-dev libv4l-dev libva-dev libvlc-dev \ + libpci-dev libdrm-dev \ + nlohmann-json3-dev libwebsocketpp-dev libasio-dev libqrcodegencpp-dev \ + libffmpeg-nvenc-dev librist-dev libsrt-openssl-dev \ + qt6-base-dev libqt6svg6-dev qt6-base-private-dev \ + libvpl-dev libvpl2 + echo '::endgroup::' +} + +setup-prebuilt-packages() { + local checkout="${PWD}" + if ! [[ -d "${checkout}/.git" && -r "${checkout}/CMakePresets.json" ]]; then + echo "::error::Action needs to be run from the root directory of an obs-studio checkout." + return 1 + fi + + mkdir -p "${OUTPUT_PATH}" + + local build_dir + { + local preset_build_dir + preset_build_dir="$(jq --raw-output ' + .configurePresets[] | select(.name == "ubuntu") | .binaryDir + ' "${checkout}/CMakePresets.json")" + build_dir="${preset_build_dir//\$\{sourceDir\}/"${OUTPUT_PATH}"}" + } + + echo '::group::Fetch Prebuilt Dependencies' + mkdir -p "${build_dir}/.deps" + + local deps_version + local deps_baseurl + local deps_hash + + local jq_result + jq_result="$(jq --raw-output --arg target "ubuntu-${BUILD_TARGET}" ' + .configurePresets[] + | select(.name == "dependencies") + | .vendor["obsproject.com/obs-studio"].dependencies["cef"] + | {version, baseUrl, "hash": .hashes[$target], "revision": .revision[$target]} + | join(" ") + ' "${checkout}/CMakePresets.json")" + + read -r deps_version deps_baseurl deps_hash deps_revision <<< "${jq_result}" + + if [[ -z "${deps_version}" ]]; then + echo '::error::No valid CEF information found in CMakePresets file.' + return 1 + fi + + pushd "${build_dir}/.deps" > /dev/null + + local filename="cef_binary_${deps_version}_linux_${BUILD_TARGET}${deps_revision:+"_v${deps_revision}"}.tar.xz" + local url="${deps_baseurl}/${filename}" + local target="cef_binary_${deps_version}_linux_${BUILD_TARGET}" + echo "CEF_VERSION=${deps_version}" >> "${GITHUB_ENV}" + + curl \ + --show-error \ + --location \ + --remote-name \ + -- "${url}" + + local shasum_output + shasum_output="$(openssl dgst -sha256 "${filename}")" + read -r _ artifact_checksum <<< "${shasum_output}" + + if [[ "${deps_hash}" != "${artifact_checksum}" ]]; then + echo '::error::Incorrect checksum of downloaded CEF dependency.' + return 1; + fi + + mkdir -p "${target}" + pushd "${target}" > /dev/null + XZ_OPT='--threads=0' tar --strip-components 1 --extract --xz --file "${build_dir}/.deps/${filename}" + popd > /dev/null + + popd > /dev/null + echo '::endgroup::' +} + +setup-ubuntu() { + setup-system-packages + setup-prebuilt-packages + + if { command -v ccache >/dev/null; } 2>&1 ; then + echo '::group::Setting up CCache' + ccache --set-config=direct_mode=true + ccache --set-config=inode_cache=true + ccache --set-config=compiler_check=content + ccache --set-config=file_clone=true + ccache --set-config=sloppiness=include_file_mtine,include_file_ctime,file_stat_matches,system_headers + ccache --set-config=cache_dir="${RUNNER_TEMP}/.ccache" + ccache --set-config=max_size="${CCACHE_SIZE:-1G}" + ccache --zero-stats > /dev/null + + local runner_os_version + runner_os_version="$(lsb_release --release --short)" + if (( "${runner_os_version%%.*}" == 24 )); then + ccache --set-config=run_second_cpp=true + fi + + ccache --show-config + + echo '::endgroup::' + fi +} + +setup-ubuntu diff --git a/.github/actions/check-changes/README.md b/.github/actions/check-changes/README.md new file mode 100644 index 00000000000000..ece1961d41b86a --- /dev/null +++ b/.github/actions/check-changes/README.md @@ -0,0 +1,79 @@ +# check-changes Action + +The check-changes action checks for changed files in a git repository based on two git refs, optionally limited by a git-style "diff" filter and a git-style "pathspec" and returns the list of changed files meeting the specified criteria and a boolean flag to use as a conditional value in workflows and actions. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `ref` | A git reference to check for changed files with. | `HEAD` | +| `base` | A git reference to check against. | `''` | +| `filter` | A git-style diff filter string to limit the kinds of changes to check for. | `''` | +| `pathspec` | A git-style "pathspec" string to limit the file paths to check for. | `''` | +| `use-fallback` | A boolean value to indicate whether to use a fallback base reference if the `ref` is invalid. | `true` | +| `working-directory` | The path from which to run the git checks. | `github.workspace` | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `has-changed-repo-files` | A boolean string to indicate whether any changed files were detected with the given constraints. | +| `changed-repo-files` | A JSON array string of file paths relative to the working-directory of all changed files. | +| `changed-files` | A JSON array string of absolute file paths of all changed files. | + +## Common Usage + +The main purpose of this action is to allow workflows or actions to abort early or only take some actions if the required kind of changes have been detected for the specified files based on the commit history between the provided git reference and the base reference. + +If no changed files have been detected by the action, the repository (or working-directory) can be considered "clean" and jobs or steps that should only run on changed files can be skipped. + +```yaml + - name: Check for Changed Files + id: checks + uses: ./.github/actions/check-changes + with: + filter: 'ACM' + pathspec: '*.c *.h *some_directory/**/*.txt :!excluded_directory/*' + + - name: Handle Result + shell: bash + env: + HAS_CHANGED: ${{ steps.checks.outputs.has-changed-files }} + run: | + if [[ "${HAS_CHANGED:-false}" == 'false' ]]; then + echo "::notice::No necessary file changes detected". + fi + + - name: Continue Jobs + if: ${{ fromJSON(steps.checks.outputs.has-changed-files) }} + ... +``` + +## Notes + +The syntax of both the diff filter as well as the pathspec is available in the git documentation. The provided values are passed directly to `git` and should support all expressions that can also be used on the command line. + +A typical diff filter value is `ACM`, which limits the changes to added, created, and modified files. Pathspecs can use both inclusions as well as exclusions (using the `:!` syntax) which allows the action to also ignore some changes and consider the repository "clean". + +### Default Base Git Refs + +The default git reference to check from is the `HEAD` ref, which is an alias for the most recent commit on the checked out branch, thus this alias depends on the checkout of the git repository in which the action is run. + +If a base reference is provided, but it cannot be resolved in the git checkout (either because it doesn't exist in the commit history or the checkout is incomplete), the git reference of the `null` tree is used by default. This will result in the entire history of the checkout to be used and can be prohibited by setting `use-fallback` to `false`, which will then make the action fail instead. + +If no base reference is provided, the action will attempt to use different values based on the event type and the state of the checkout: + +* By default the `HEAD~1` alias is used, which should point to the commit before the current `HEAD` commit. This requires a checkout depth of at least `2`. +* If a GitHub `pull_request` event is detected, the `HEAD` commit of the base branch targeted by the pull request is used. +* If a GitHub `push` request is detected and the event is not the result of a force-push, use the reference provided by the event's `before` property. which potentially provides the SHA of the most recent commit on the same branch before the current push. + * If this SHA is invalid, the `null` tree is used instead. + +## Developer Notes + +The action is a convenience wrapper around `git diff`, composing a corresponding invocation of the command-line tool and also parsing the output into corresponding JSON objects for use with other workflows or actions. + +The git ref `4b825dc642cb6eb9a060e54bf8d69288fbee4904` is a "hidden" SHA hash of the "empty tree" (which the action yields programmatically by hashing the `/dev/null` tree). This SHA will always exist and is used as a last-resort fallback (if not disabled by the user) to ensure that the invocation of `git diff` always gets at least one valid git ref. + +* Absolute file paths are converted into Windows paths on Windows GitHub Actions runners. diff --git a/.github/actions/check-changes/action.yaml b/.github/actions/check-changes/action.yaml index 40e4a3ae295921..c0a250ac8910d0 100644 --- a/.github/actions/check-changes/action.yaml +++ b/.github/actions/check-changes/action.yaml @@ -1,82 +1,50 @@ name: Check For Changed Files -description: Checks for changed files compared to specific git reference and glob expression +description: > + Checks for changed files in a git repository based on two git refs, optionally limited by a git-style "diff" filter + and a git-style "pathspec" and returns the list of changed files meeting the specified criteria and a boolean flag + to use as a conditional value in workflows and actions. inputs: - baseRef: - description: Git reference to check against - required: false ref: - description: Git reference to check with - required: false + description: A git reference to check for changed files with. default: HEAD - checkGlob: - description: Glob expression to limit check to specific files - required: false - useFallback: - description: Use fallback compare against prior commit - required: false - default: 'true' - diffFilter: - description: git diff-filter string to use - required: false + base: + description: A git reference to check against. + default: '' + filter: + description: A git-style diff filter string to limit the kinds of changes to check for. default: '' + pathspec: + description: A git-style 'pathspec' string to limit the file paths to check for. + default: '' + use-fallback: + description: A boolean value to indicate whether to use a fallback base reference if the 'ref' is invalid. + default: 'true' + working-directory: + description: The path from which to run the git checks. + default: ${{ github.workspace }} outputs: - hasChangedFiles: - value: ${{ steps.checks.outputs.hasChangedFiles }} - description: True if specified files were changed in comparison to specified git reference - changedFiles: - value: ${{ steps.checks.outputs.changedFiles }} - description: List of changed files + has-changed-files: + value: ${{ steps.checks.outputs.has-changed-files }} + description: A boolean string to indicate whether any changed files were detected with the given constraints. + changed-repo-files: + value: ${{ steps.checks.outputs.changed-repo-files }} + description: A JSON array string of file paths relative to the working-directory of all changed files. + changed-files: + value: ${{ steps.checks.outputs.changed-files }} + description: A JSON array string of absolute file paths of all changed files. runs: using: composite steps: - - name: Check For Changed Files ✅ - shell: bash + - name: Check For Changed Files + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.working-directory }} id: checks env: - GIT_BASE_REF: ${{ inputs.baseRef }} GIT_REF: ${{ inputs.ref }} + GIT_BASE_REF: ${{ inputs.base }} + DIFF_FILTER: ${{ inputs.filter }} + PATH_SPEC: ${{ inputs.pathspec }} + USE_FALLBACK: ${{ inputs.use-fallback }} GITHUB_EVENT_FORCED: ${{ github.event.forced }} GITHUB_REF_BEFORE: ${{ github.event.before }} - USE_FALLBACK: ${{ inputs.useFallback }} - DIFF_FILTER: ${{ inputs.diffFilter }} - run: | - : Check for Changed Files ✅ - if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi - shopt -s extglob - shopt -s dotglob - - # 4b825dc642cb6eb9a060e54bf8d69288fbee4904 is a "hidden" sha1 hash of - # the "empty tree", retrieved via 'git hash-object -t tree /dev/null', - # and used here as a last-resort fallback to always provide a valid - # git ref. - - if [[ "${GIT_BASE_REF}" ]]; then - if ! git cat-file -e "${GIT_BASE_REF}" &> /dev/null; then - echo "::warning::Provided base reference ${GIT_BASE_REF} is invalid" - if [[ "${USE_FALLBACK}" == 'true' ]]; then - GIT_BASE_REF='HEAD~1' - fi - fi - else - if ! git cat-file -e ${GITHUB_REF_BEFORE} &> /dev/null; then - GITHUB_REF_BEFORE='4b825dc642cb6eb9a060e54bf8d69288fbee4904' - fi - - GIT_BASE_REF='HEAD~1' - case "${GITHUB_EVENT_NAME}" in - pull_request) GIT_BASE_REF="origin/${GITHUB_BASE_REF}" ;; - push) if [[ "${GITHUB_EVENT_FORCED}" != 'true' ]]; then GIT_BASE_REF="${GITHUB_REF_BEFORE}"; fi ;; - *) ;; - esac - fi - - changes=($(git diff --name-only --diff-filter="${DIFF_FILTER}" ${GIT_BASE_REF} ${GIT_REF} -- ${{ inputs.checkGlob }})) - - if (( ${#changes[@]} )); then - file_string="${changes[*]}" - echo "hasChangedFiles=true" >> $GITHUB_OUTPUT - echo "changedFiles=[\"${file_string// /\",\"}\"]" >> $GITHUB_OUTPUT - else - echo "hasChangedFiles=false" >> $GITHUB_OUTPUT - echo "changedFiles=[]" >> GITHUB_OUTPUT - fi + run: ${GITHUB_ACTION_PATH}/check-changes.bash diff --git a/.github/actions/check-changes/check-changes.bash b/.github/actions/check-changes/check-changes.bash new file mode 100755 index 00000000000000..e927180dd8a10c --- /dev/null +++ b/.github/actions/check-changes/check-changes.bash @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +select-git-ref() { + # 4b825dc642cb6eb9a060e54bf8d69288fbee4904 is a "hidden" sha1 hash of + # the "empty tree", retrieved via 'git hash-object -t tree /dev/null', + # and used here as a last-resort fallback to always provide a valid + # git ref. + + local empty_tree_hash + empty_tree_hash="$(git hash-object -t tree /dev/null)" + + echo '::group::Checking for compatible git ref' + # If base ref is provided, check if it represents a valid object in the source tree. If enabled, + # use 'HEAD~1' (the commit before the current one) to compare against. + if [[ -n "${GIT_BASE_REF:-}" ]]; then + if ! git cat-file -e "${GIT_BASE_REF}" &> /dev/null; then + if [[ "${USE_FALLBACK:-false}" == 'true' ]]; then + echo "::warning::Provided base reference '${GIT_BASE_REF}' is invalid. Using 'HEAD~1' instead." + GIT_BASE_REF='HEAD~1' + else + echo "::error::Provided base reference '${GIT_BASE_REF}' is invalid." + return 1 + fi + fi + else + # If base ref is not provided, check if the SHA of the most recent commit on ref before the current + # push is a valid object. Use the fallback "empty tree" hash otherwise. This will effectively list + # all changes since the beginning of the repository. + if ! git cat-file -e "${GITHUB_REF_BEFORE:-}" &> /dev/null; then + GITHUB_REF_BEFORE="${empty_tree_hash}" + fi + + # Start with the commit before the current one as a baseline. + GIT_BASE_REF='HEAD~1' + case "${GITHUB_EVENT_NAME:-}" in + pull_request) + # Use the target branch of the pull request to compare against. + echo "Using pull request target branch '${GITHUB_BASE_REF}'." + GIT_BASE_REF="origin/${GITHUB_BASE_REF}" + + if ! git show-ref --exists "${GIT_BASE_REF}" &> /dev/null; then + git fetch origin + fi + ;; + push) + # Use the SHA of the most recent commit on ref before the current + # push to compare against. + if [[ "${GITHUB_EVENT_FORCED:-false}" != 'true' ]]; then + echo "Normal push detected. Using most recent ref before current push '${GITHUB_REF_BEFORE}'." + GIT_BASE_REF="${GITHUB_REF_BEFORE}" + else + echo "Force push detected. Using ref before current one 'HEAD~1'." + fi + ;; + *) ;; + esac + fi + echo '::endgroup::' +} + +check-changes() { + select-git-ref + + local -a path_spec + read -a path_spec -r <<< "${PATH_SPEC:-.}" + + local diff_content + diff_content="$(git diff \ + --name-only \ + --diff-filter="${DIFF_FILTER:-}" \ + "${GIT_BASE_REF}" \ + "${GIT_REF}" \ + -- \ + "${path_spec[@]}")" + + local full_path + local -a changes_absolute=() + local -a changes_relative=() + + while read -r file; do + if [[ -n "${file}" ]]; then + full_path="$(realpath "${file}")" + if [[ "${RUNNER_OS}" == 'Windows' ]]; then + full_path="$(cygpath --windows "${full_path}")" + fi + changes_absolute+=("${full_path}") + changes_relative+=("${file}") + fi + done <<< "${diff_content}" + + { + if (( ${#changes_absolute[@]} )); then + local json_changes_absolute + json_changes_absolute="$(jq --compact-output --monochrome-output --raw-input ' + .,inputs | split(" ") + ' <<< "${changes_absolute[@]}")" + + local json_changes_relative + json_changes_relative="$(jq --compact-output --monochrome-output --raw-input ' + .,inputs | split(" ") + ' <<< "${changes_relative[@]}")" + + echo "has-changed-files=true" + echo "changed-files=${json_changes_absolute}" + echo "changed-repo-files=${json_changes_relative}" + else + echo "has-changed-files=false" + echo "changed-files=[]" + echo "changed-repo-files=[]" + fi + } >> "${GITHUB_OUTPUT}" +} + +check-changes diff --git a/.github/actions/check-runner/README.md b/.github/actions/check-runner/README.md new file mode 100644 index 00000000000000..b1fe4595537254 --- /dev/null +++ b/.github/actions/check-runner/README.md @@ -0,0 +1,37 @@ +# check-runner Action + +The check-runner action checks the current runner against the set of implemented conditions. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `os` | A multiline string of runner operating system strings. | `macOS Windows Linux` | +| `custom-error` | A custom error message to use when runner fails checks. | `''` | + +### Outputs + +The action has no output. + +## Common Usage + +```yaml + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Check Runner Operating System + uses: ./.github/actions/check-runner + with: + os: | + Windows + macOS +``` + +## Notes + +* Additional checks of the runner environment required by another action or workflow should be implemented in this action and selected/applied when corresponding inputs are provided. diff --git a/.github/actions/check-runner/action.yaml b/.github/actions/check-runner/action.yaml new file mode 100644 index 00000000000000..01dd2d788fa59d --- /dev/null +++ b/.github/actions/check-runner/action.yaml @@ -0,0 +1,21 @@ +name: Check Runner +description: Checks the current runner against the set of implemented conditions. +inputs: + os: + description: A multiline string of runner operating system strings. + default: | + macOS + Windows + Linux + custom-error: + description: A custom error message to use when runner fails checks. + default: '' +runs: + using: composite + steps: + - name: Check Runner + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + CHECK_OS: ${{ inputs.os }} + CUSTOM_ERROR: ${{ inputs.custom-error }} + run: ${GITHUB_ACTION_PATH}/check-runner.bash diff --git a/.github/actions/check-runner/check-runner.bash b/.github/actions/check-runner/check-runner.bash new file mode 100755 index 00000000000000..10161121ee20e9 --- /dev/null +++ b/.github/actions/check-runner/check-runner.bash @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +check-runner() { + local error_message + + local -i has_supported_os=0 + local os_candidate + while read -r os_candidate; do + if [[ "${RUNNER_OS}" == "${os_candidate}" ]]; then + has_supported_os=1 + fi + done <<< "${CHECK_OS}" + + if (( ! has_supported_os )); then + error_message="Unsupported runner operating system '${RUNNER_OS}'." + if [[ -n "${CUSTOM_ERROR:-}" ]]; then + error_message="${CUSTOM_ERROR}" + fi + + echo "::error::${error_message}" + return 1 + fi +} + +check-runner diff --git a/.github/actions/check-version-tag/README.md b/.github/actions/check-version-tag/README.md new file mode 100644 index 00000000000000..f2898ececa128c --- /dev/null +++ b/.github/actions/check-version-tag/README.md @@ -0,0 +1,56 @@ +# check-version-tag Action + +The check-version-tag action checks whether the name of the provided git ref represents a semantic version string. If possible, the version segments are extracted from a valid version string and the action can also optionally fail if no valid version string is detected. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `ref` | A git reference to check for a semantic version string. | `REQUIRED` | +| `fail-on-mismatch` | A boolean value to indicate whether the action should fail if no semantic version was detected. | `false` | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `version` | A string representing the full version. | +| `major` | A string representing the major version segment. | +| `minor` | A string representing the minor version segment. | +| `patch` | A string representing the patch version segment. | +| `pre-release` | A string representing the pre-release version segment (e.g. `-rc2`). | +| `number` | A string representing just the pre-release number (e.g. `2`).| +| `is-pre-release` | A boolean string representing whether or not the git ref is a valid pre-release semantic version. | +| `is-valid-semver` | A boolean string representing whether or not the git ref is a valid semantic version. | + +## Common Usage + +This action can commonly be used to check if the tag name of a pushed tag actually represents a semantic version (e.g. `5.0.0-beta2`) and allows a workflow to either abort or change its behavior. This also allows GitHub Actions to differentiate between a pushed tag for a release or a pushed tag for any other purpose. + +This also allows dispatched workflows to abort early if they have been triggered with a git ref that does not represent a semantic version string. + +```yaml + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Generate Semver From Tag + id: semver + uses: ./.github/actions/check-version-tag + with: + ref: ${{ github.ref_name }} + fail-on-mismatch: true +``` + +## Notes + +The action only checks the git ref's name, but not the _type_ of the git ref. Thus the action will yield outputs (and succeed) if the git ref represents a branch named `2.5.0` and not only a tag. + +To ensure that a workflow only runs on a tag and that tag uses a semantic version name, the `github.ref_type` value needs to be combined with the result of this action. + +## Developer Notes + +The action runs a simple regular expression on the provided git ref to yield matches for a common semantic version string of the form `<1-n digits>.<1-n digits>.<1-n digits>-<1-n digits>`. There is no further processing beyond that. If Bash's regular expression matching fails, the git ref is not considered a semantic version string. diff --git a/.github/actions/check-version-tag/action.yaml b/.github/actions/check-version-tag/action.yaml new file mode 100644 index 00000000000000..630b0c1112dee2 --- /dev/null +++ b/.github/actions/check-version-tag/action.yaml @@ -0,0 +1,47 @@ +name: Check Version Tag +description: > + Checks whether the name of the provided git ref represents a semantic version string. If possible, the version + segments are extracted from a valid version string and the action can also optionally fail if no valid version string + is detected. +inputs: + ref: + description: A git reference to check for a semantic version string. + required: true + fail-on-mismatch: + description: A boolean value to indicate whether the action should fail if no semantic version was detected. + default: 'false' +outputs: + version: + description: A string representing the full version. + value: ${{ steps.semver.outputs.version }} + major: + description: A string representing the major version segment. + value: ${{ steps.semver.outputs.major }} + minor: + description: A string representing the minor version segment. + value: ${{ steps.semver.outputs.minor }} + patch: + description: A string representing the patch version segment. + value: ${{ steps.semver.outputs.patch }} + pre-release: + description: A string representing the pre-release version segment (e.g. '-rc2'). + value: ${{ steps.semver.outputs.pre-release }} + number: + description: A string representing just the pre-release number (e.g. '2'). + value: ${{ steps.semver.outputs.number }} + is-pre-release: + description: A boolean string representing whether or not the git ref is a valid pre-release semantic version. + value: ${{ steps.semver.outputs.pre-release != '' }} + is-valid-semver: + description: A boolean string representing whether or not the git ref is a valid semantic version. + value: ${{ steps.semver.outputs.is-valid-semver }} +runs: + using: composite + steps: + - name: Check Version Tag + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + id: semver + env: + GIT_REF: ${{ inputs.ref }} + FAIL_ON_MISMATCH: ${{ inputs.fail-on-mismatch }} + run: ${GITHUB_ACTION_PATH}/check-version-tag.bash diff --git a/.github/actions/check-version-tag/check-version-tag.bash b/.github/actions/check-version-tag/check-version-tag.bash new file mode 100755 index 00000000000000..5464ea6c0a7cdb --- /dev/null +++ b/.github/actions/check-version-tag/check-version-tag.bash @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +check-version-tag() { + local version_regex='^(([0-9]+)\.([0-9]+)\.([0-9]+))(-(rc|beta)([0-9]+))?$' + + if [[ "${GIT_REF}" =~ ${version_regex} ]]; then + { + echo "version=${BASH_REMATCH[0]}" + echo "major=${BASH_REMATCH[2]}" + echo "minor=${BASH_REMATCH[3]}" + echo "patch=${BASH_REMATCH[4]}" + } >> "${GITHUB_OUTPUT}" + + if [[ -n "${BASH_REMATCH[5]}" ]]; then + { + echo "pre-release=${BASH_REMATCH[5]}" + echo "number=${BASH_REMATCH[7]}" + } >> "${GITHUB_OUTPUT}" + echo "Semantic pre-release version detected for ${GIT_REF}." + else + echo "Semantic version detected for ${GIT_REF}." + fi + + echo "is-valid-semver=true" + return 0 + fi + + echo "is-valid-semver=false" >> "${GITHUB_OUTPUT}" + local error_message="No semantic version detected for ${GIT_REF}." + if [[ "${FAIL_ON_MISMATCH:-false}" == 'true' ]]; then + echo "::error::${error_message}" + return 1 + else + echo "${error_message}" + fi +} + +check-version-tag diff --git a/.github/actions/clean-cache/README.md b/.github/actions/clean-cache/README.md new file mode 100644 index 00000000000000..91dca13316f01d --- /dev/null +++ b/.github/actions/clean-cache/README.md @@ -0,0 +1,53 @@ +# clean-cache Action + +The clean-cache action removes existing GitHub Actions caches given the git ref and cache key. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `ref` | The git reference the caches were created from. | `refs/heads/master` | +| `name` | Either the name of a specific GitHub Actions cache or a regular expression pattern for possible matches. | `REQUIRED` | +| `github-token` | The GitHub token required for the `gh` command-line utility to interact with GitHub Actions caches. | `github.token` | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `caches-cleaned` | A boolean value indicating whether any caches have been cleaned. | + +## Common Usage + +The action has no prerequisites (apart from a repository checkout to provide the action itself). + +```yaml + - name: Remove Compilation Caches + uses: ./.github/actions/clean-cache + with: + name: '(macos|ubuntu-.*)-compilation-(x86_64|arm64)-.*' + ref: refs/heads/master +``` + +## Notes + +Both the `name` as well as the `ref` can be regular expressions, so it's not necessary to provide exact matching names. Use this with care, as this can lead to the action cleaning more caches than intended. + +## Developer Notes + +The action uses a `jq` query in combination with the `gh` command-line utility to retrieve a possible list of matching cache entries. + +```typescript + .actions_caches.[] + // Select only cache entries for the specified git reference expression + | select(.ref | test($git_ref)) + // Select only cache entries for the specified cache name expression + | select(.key | test($cache_name)) + // Select only the id, key (with spaces converted to URL encoded '%20'), and git reference + | {id, key: (.key | sub(" ", "%20", "g")), ref} + // Output as word-separated string + | join (" ") +``` + +The `test` command supports regular expressions and thus the action itself also supports regular expressions for both the `ref` and `name` inputs. A ref like `refs/pull-requests/[0-9]+` can be used to potentially clean all caches generated by pull request runs. diff --git a/.github/actions/clean-cache/action.yaml b/.github/actions/clean-cache/action.yaml new file mode 100644 index 00000000000000..2603851f827581 --- /dev/null +++ b/.github/actions/clean-cache/action.yaml @@ -0,0 +1,28 @@ +name: Clean Cache +description: Removes existing GitHub Actions caches given the git ref and cache key. +inputs: + ref: + description: The git reference the caches were created from. + default: 'refs/heads/master' + name: + description: > + Either the name of a specific GitHub Actions cache or a regular expression pattern for possible matches. + required: true + github-token: + description: The GitHub token required for the 'gh' command-line utility to interact with GitHub Actions caches. + default: ${{ github.token }} +outputs: + caches-cleaned: + description: A boolean value indicating whether any caches have been cleaned. + value: ${{ steps.clean.outputs.cleaned }} +runs: + using: composite + steps: + - name: Clean Cache + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + id: clean + env: + GH_TOKEN: ${{ inputs.github-token }} + GIT_REF: ${{ inputs.ref }} + CACHE_NAME: ${{ inputs.name }} + run: ${GITHUB_ACTION_PATH}/clean-caches.bash diff --git a/.github/actions/clean-cache/clean-caches.bash b/.github/actions/clean-cache/clean-caches.bash new file mode 100755 index 00000000000000..b068f32a1e47b6 --- /dev/null +++ b/.github/actions/clean-cache/clean-caches.bash @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +clean-caches() { + local gh_output + gh_output="$(gh api "repos/${GITHUB_REPOSITORY}/actions/caches" \ + --jq " + .actions_caches.[] + | select(.ref | test(\"${GIT_REF}\")) + | select(.key | test(\"${CACHE_NAME}\")) + | {id, key: (.key | sub(\" \";\"%20\";\"g\")), ref} + | join (\" \") + ")" + + local cache_key + local cache_ref + local -i deleted_amount=0 + local -i result=0 + + while read -r _ cache_key cache_ref; do + if [[ -n "${cache_key}" ]]; then + if result="$(gh api -X DELETE "repos/${GITHUB_REPOSITORY}/actions/caches?key=${cache_key}" \ + --jq '.total_count' 2>/dev/null)"; then + echo "Deleted cache entry '${cache_key//'%20'/ }' for git ref '${cache_ref}'." + + deleted_amount=$(( deleted_amount + result )) + else + echo "::warning::Unable to delete cache entry '${cache_key//'%20'/ }'." + fi + fi + done <<< "${gh_output}" + + echo "cleaned=${deleted_amount}" >> "${GITHUB_OUTPUT}" +} + +clean-caches diff --git a/.github/actions/codesign-obs/codesign-macos/README.md b/.github/actions/codesign-obs/codesign-macos/README.md new file mode 100644 index 00000000000000..09f93b7bbcfcf9 --- /dev/null +++ b/.github/actions/codesign-obs/codesign-macos/README.md @@ -0,0 +1,59 @@ +# codesign-macos Action + +The codesign-macos action code signs and optionally notarizes an existing OBS Studio disk image. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `path` | A path to an OBS Studio disk image file present on the GitHub Actions runner. | `REQUIRED` | +| `identity` | The Apple Developer ID to code sign the disk image with. | `REQUIRED` | +| `team` | The Apple Developer team ID to code sign the disk image with. | `REQUIRED` | +| `notarize` | A boolean value to indicate whether the disk image should also be notarized. | `false` | +| `notarization-user` | The Apple ID used to authenticate with Apple's notarization servers. | `''` | +| `notarization-password` | The app password to authenticate with Apple's notarization servers. | `''` | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `path` | The path to the code signed and optionally notarized OBS Studio disk image. | + +## Common Usage + +The action requires the corresponding Apple Developer certificate the be installed on the macOS GitHub Actions runner before calling this action. + +```yaml + - name: Set Up Code Signing + ... + + - name: Code Sign macOS Disk Image + id: codesign + uses: ./.github/actions/codesign-obs/codesign-macos + with: + path: ${{ format('{0}/obs-studio-arm64.dmg', runner.temp) }} + identity: ${{ secrets.macos-developer-id }} + team: ${{ secrets.macos-developer-team }} + notarize: true + notarization-user: ${{ secrets.macos-apple-id }} + notarization-password: ${{ secrets.macos-apple-password }} +``` + +Do not rely on the action to code sign and notarize the disk image in place, use the `path` output instead to unambiguously identify the output disk image on the GitHub Actions runner. + +## Notes + +> [!IMPORTANT] +> The action requires a macOS GitHub Actions runner. + +The `codesign-obs/setup-macos` action can be used to set up an Apple Developer certificate on the GitHub Actions runner before running this action. + +While it is not necessary to provide an actual Apple Developer ID and team ID (providing just a dash `-` as the Developer ID and an empty string as the team ID is sufficient), the disk image will only receive an ad-hoc signature that is valid outside of the GitHub Actions runner. + +Thus there is no benefit for distribution between an unsigned disk image and a disk image signed with an ad-hoc profile. + +## Developer Notes + +The action uses `codesign` and `xcrun notarytool` to handle both code signing and notarization. The first requires an Apple Developer certificate matching the provided developer ID to be installed in the GitHub Actions runner's key-chain, while the former can store and use the credentials directly. diff --git a/.github/actions/codesign-obs/codesign-macos/action.yaml b/.github/actions/codesign-obs/codesign-macos/action.yaml new file mode 100644 index 00000000000000..3346f2f561f98b --- /dev/null +++ b/.github/actions/codesign-obs/codesign-macos/action.yaml @@ -0,0 +1,45 @@ +name: Code Sign and Notarize macOS Disk Image +description: Code signs and optionally notarizes an existing OBS Studio disk image +inputs: + path: + description: A path to an OBS Studio disk image file present on the runner. + required: true + identity: + description: The Apple Developer ID to code sign the disk image with. + required: true + team: + description: The Apple Developer team ID to code sign the disk image with. + required: true + notarize: + description: A boolean value to indicate whether the disk image should also be notarized. + default: 'false' + notarization-user: + description: The Apple ID used to authenticate with Apple's notarization servers. + default: '' + notarization-password: + description: The app password to authenticate with Apple's notarization servers. + default: '' +outputs: + path: + description: Path to code signed and notarized disk image. + value: ${{ steps.codesign.outputs.path }} +runs: + using: composite + steps: + - name: Check Runner + uses: ./.github/actions/check-runner + with: + os: macOS + custom-error: codesign-obs/codesign-macos action requires a macOS runner. + + - name: Code Sign macOS Disk Image + id: codesign + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + DISK_IMAGE: ${{ inputs.path }} + CODESIGN_IDENT: ${{ inputs.identity }} + CODESIGN_TEAM: ${{ inputs.team }} + RUN_NOTARIZATION: ${{ inputs.notarize }} + NOTARIZATION_USER: ${{ inputs.notarization-user }} + NOTARIZATION_PASSWORD: ${{ inputs.notarization-password }} + run: zsh --errexit --pipefail --no-rcs ${GITHUB_ACTION_PATH}/codesign-macos.zsh diff --git a/.github/actions/codesign-obs/codesign-macos/codesign-macos.zsh b/.github/actions/codesign-obs/codesign-macos/codesign-macos.zsh new file mode 100755 index 00000000000000..18b9b4b035d1a1 --- /dev/null +++ b/.github/actions/codesign-obs/codesign-macos/codesign-macos.zsh @@ -0,0 +1,72 @@ +#!/usr/bin/env zsh + +builtin emulate -L zsh +setopt PUSHD_SILENT +setopt EXTENDED_GLOB +setopt ERR_EXIT +setopt ERR_RETURN +setopt NO_UNSET +setopt PIPE_FAIL +setopt NO_AUTO_PUSHD +setopt NO_PUSHD_IGNORE_DUPS +setopt NO_GLOB_SUBST +setopt WARN_CREATE_GLOBAL +setopt WARN_NESTED_VAR + +: ${CI:?} +if (( ${+RUNNER_DEBUG} )) setopt XTRACE + +codesign-disk-image() { + codesign --sign ${CODESIGN_IDENT} ${DISK_IMAGE} +} + +notarize-disk-image() { + if ! [[ -n "${CODESIGN_IDENT}" \ + && -n "${CODESIGN_TEAM}" \ + && -n "${NOTARIZATION_USER}" \ + && -n "${NOTARIZATION_PASSWORD}" ]] \ + { + print '::error::Notarization requires Apple ID and application password.' + return 1 + } + + print '::group::Notarize Disk Image' + local storage_identifier + function { + setopt NOXTRACE + storage_identifier="$(head --bytes=32 /dev/urandom | xxd -p -cols 0)" + print "::add-mask::${storage_identifier}" + } + + xcrun notarytool store-credentials ${storage_identifier} \ + --apple-id ${NOTARIZATION_USER} \ + --team-id ${CODESIGN_TEAM} \ + --password ${NOTARIZATION_PASSWORD} + + xcrun notarytool submit ${DISK_IMAGE} --keychain-profile ${storage_identifier} --wait + + xcrun stapler staple ${DISK_IMAGE} + + print '::endgroup::' +} + +codesign-macos() { + if [[ ! -r ${DISK_IMAGE} ]] { + print "::error::No macOS disk image found at '${DISK_IMAGE}'." + return 1 + } + + codesign-disk-image + + if [[ "${RUN_NOTARIZATION:-false}" == 'true' ]] { + notarize-disk-image + } + + local output_name="${DISK_IMAGE//unsigned/signed}" + + mv ${DISK_IMAGE} ${output_name} + + print "path=${output_name}" >> ${GITHUB_OUTPUT} +} + +codesign-macos diff --git a/.github/actions/codesign-obs/codesign-windows/README.md b/.github/actions/codesign-obs/codesign-windows/README.md new file mode 100644 index 00000000000000..50aec801179d63 --- /dev/null +++ b/.github/actions/codesign-obs/codesign-windows/README.md @@ -0,0 +1,51 @@ +# codesign-windows Action + +The codesign-windows action code signs an existing OBS Studio Windows build provided as a `.zip` archive. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `path` | A path to an OBS Studio build archive in `.zip` format present on the GitHub Actions runner. | (Required) | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `path` | The path to a new archive in `.zip` format with the code signed OBS Studio build. | + +## Common Usage + +The action requires a Windows GitHub Actions runner and the Google Cloud authentication setup to have succeeded before calling this action. + +```yaml + - name: Set Up Code Signing + ... + + - name: Sign Windows Build + id: codesign + uses: ./.github/actions/codesign-obs/codesign-windows + with: + path: ${{ format('{0}/obs-studio-Windows-x64.zip', runner.temp) }} +``` + +Do not rely on the action to code sign the build archive in place, use the `path` output instead to unambiguously identify the output archive on the GitHub Actions runner. + +## Notes + +> [!IMPORTANT] +> The action requires a Windows GitHub Actions runner. + +The `codesign-obs/setup-windows` action can be used to set up the Google Cloud credentials on the GitHub Actions runner before running this action. + +The game capture hook is separately code signed using an RSA-based certificate due to Microsoft's post-quantum cryptography requirements. + +## Developer Notes + +This action uses `signtool` to apply code signing to all code files (executable and shared libraries) present in an OBS Studio build. + +For signing the entire build, a chunk size of 5 files at a time is used to mimic the behavior of `bouf`. While the reason for this exact chunk size is lost to time, the action uses this value as a starting point. + +* File paths are handled in their UNIX variant and converted from and to Windows format at the input/output edge of the Bash script. diff --git a/.github/actions/codesign-obs/codesign-windows/action.yaml b/.github/actions/codesign-obs/codesign-windows/action.yaml new file mode 100644 index 00000000000000..c1210f771544d6 --- /dev/null +++ b/.github/actions/codesign-obs/codesign-windows/action.yaml @@ -0,0 +1,67 @@ +name: Sign Windows Builds +description: Code signs an existing OBS Studio Windows build provided as a '.zip' archive. +inputs: + path: + description: A path to an OBS Studio build archive in '.zip' format present on the runner. + required: true +outputs: + path: + description: The path to a new archive in '.zip' format with the code signed OBS Studio build. + value: ${{ steps.compress.outputs.path }} +runs: + using: composite + steps: + - name: Check Runner + uses: ./.github/actions/check-runner + with: + os: Windows + custom-error: codesign-obs/codesign-windows action requires a Windows runner. + + - name: Extract Asset + id: extract + env: + RELEASE_ASSET: ${{ inputs.path }} + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + run: | + # Extract Asset + if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + + declare asset_basename + asset_basename="$(basename "${RELEASE_ASSET}")" + + mkdir -p "${RUNNER_TEMP}/signed-build" + cd "${RUNNER_TEMP}/signed-build" + if [[ -d "${RELEASE_ASSET}" ]]; then + RELEASE_ASSET="${RELEASE_ASSET}/${asset_basename}" + fi + unzip "${RELEASE_ASSET}" + rm "${RELEASE_ASSET}" + + - name: Sign Game Capture with RSA cert + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + ARTIFACT_PATH: ${{ format('{0}/signed-build', runner.temp) }} + run: ${GITHUB_ACTION_PATH}/sign-gamecapture.bash + + - name: Sign OBS Studio package + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + ARTIFACT_PATH: ${{ format('{0}/signed-build', runner.temp) }} + run: ${GITHUB_ACTION_PATH}/sign-package.bash + + - name: Compress Asset + id: compress + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ format('{0}/signed-build', runner.temp) }} + env: + RELEASE_ASSET: ${{ inputs.path }} + run: | + # Compress Asset + if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + + signed_asset="${RELEASE_ASSET//unsigned/signed}" + 7z a -mx1 "${signed_asset}" "${PWD}"/* + + declare output_path + output_path="$(cygpath --windows "${signed_asset}")" + echo "path=${output_path}" >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/windows-signing/prod-gc.crt b/.github/actions/codesign-obs/codesign-windows/prod-gc.crt similarity index 100% rename from .github/actions/windows-signing/prod-gc.crt rename to .github/actions/codesign-obs/codesign-windows/prod-gc.crt diff --git a/.github/actions/windows-signing/prod.crt b/.github/actions/codesign-obs/codesign-windows/prod.crt similarity index 100% rename from .github/actions/windows-signing/prod.crt rename to .github/actions/codesign-obs/codesign-windows/prod.crt diff --git a/.github/actions/codesign-obs/codesign-windows/sign-gamecapture.bash b/.github/actions/codesign-obs/codesign-windows/sign-gamecapture.bash new file mode 100755 index 00000000000000..a08e85e92d5f30 --- /dev/null +++ b/.github/actions/codesign-obs/codesign-windows/sign-gamecapture.bash @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +sign-gamecapture() { + local signtool_location="${PROGRAMFILES} (x86)\\Windows Kits\\10\\App Certification Kit\\signtool.exe" + local signtool + signtool=$(cygpath --unix "${PROGRAMFILES} (x86)/Windows Kits/10/App Certification Kit/signtool.exe") + + if [[ ! -r "${signtool}" ]]; then + echo "::error::Signtool not found at '${signtool_location}'." + return 1 + fi + + ARTIFACT_PATH="$(cygpath --unix "${ARTIFACT_PATH}")" + + local gamecapture_path="${ARTIFACT_PATH}/data/obs-plugins/win-capture" + + if [[ ! -r "${gamecapture_path}" ]]; then + local windows_path + windows_path="$(cygpath --windows "${gamecapture_path}")" + echo "::error::No game capture module found at '${windows_path}'." + return 1 + fi + + local key_project="projects/ci-signing" + local key_location="locations/global" + local key_ring="keyRings/production" + local key_name="cryptoKeys/game-capture-release-sign-hsm" + local key_version="cryptoKeyVersions/1" + local kms_key="${key_project}/${key_location}/${key_ring}/${key_name}/${key_version}" + + local -a signtool_arguments=( + "//fd" sha256 + "//t" "http://timestamp.digicert.com" + "//f" "${GITHUB_ACTION_PATH}/prod-gc.crt" + "//csp" "Google Cloud KMS Provider" + "//kc" "${kms_key}" + ) + + local output + local -a gamecapture_dlls=() + if output="$(compgen -G "${gamecapture_path}/*.dll")"; then + while read -r file; do + gamecapture_dlls+=("${file}") + done <<< "${output}" + fi + + if (( ! ${#gamecapture_dlls[@]} )); then + echo "::warning::No game capture files found in '${gamecapture_path}." + return 0 + fi + + "${signtool}" sign "${signtool_arguments[@]}" "${gamecapture_dlls[@]}" +} + +sign-gamecapture diff --git a/.github/actions/codesign-obs/codesign-windows/sign-package.bash b/.github/actions/codesign-obs/codesign-windows/sign-package.bash new file mode 100755 index 00000000000000..375cbc1f20cecf --- /dev/null +++ b/.github/actions/codesign-obs/codesign-windows/sign-package.bash @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +shopt -s globstar +shopt -s extglob + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +sign-project() { + local signtool_location="${PROGRAMFILES} (x86)\\Windows Kits\\10\\App Certification Kit\\signtool.exe" + local signtool + signtool=$(cygpath --unix "${PROGRAMFILES} (x86)/Windows Kits/10/App Certification Kit/signtool.exe") + + if [[ ! -r "${signtool}" ]]; then + echo "::error::Signtool not found at '${signtool_location}'." + return 1 + fi + + ARTIFACT_PATH="$(cygpath --unix "${ARTIFACT_PATH}")" + + local key_project="projects/ci-signing" + local key_location="locations/global" + local key_ring="keyRings/production" + local key_name="cryptoKeys/release-sign-hsm" + local key_version="cryptoKeyVersions/1" + local kms_key="${key_project}/${key_location}/${key_ring}/${key_name}/${key_version}" + + local -a signtool_arguments=( + "//fd" 'sha384' + "//as" + "//tr" "http://timestamp.digicert.com" + "//td" 'sha256' + "//f" "${GITHUB_ACTION_PATH}/prod.crt" + "//csp" 'Google Cloud KMS Provider' + "//kc" "${kms_key}" + ) + + local -a project_files=() + local output + if output="$(compgen -G "${ARTIFACT_PATH}/**/*.@(exe|dll|pyd)")"; then + while read -r file; do + project_files+=("${file}") + done <<< "${output}" + fi + + if (( ! ${#project_files[@]} )); then + echo "::warning::No OBS Studio files found in '${ARTIFACT_PATH}." + return 0 + fi + + + local -i chunk_size=5 + local -i num_items="${#project_files[@]}" + local -i num_chunks="$(( (num_items + chunk_size - 1) / chunk_size))" + local -i seq_end="$(( num_chunks - 1 ))" + + local sequence + sequence="$(seq 0 "${seq_end}")" + + local i + while read -r i; do + local -i start_index=$(( i * chunk_size )) + local -a slice=("${project_files[@]:${start_index}:${chunk_size}}") + + "${signtool}" sign "${signtool_arguments[@]}" "${slice[@]}" + done <<< "${sequence}" +} + +sign-project diff --git a/.github/actions/codesign-obs/setup-macos/README.md b/.github/actions/codesign-obs/setup-macos/README.md new file mode 100644 index 00000000000000..f36d4cfdfbf51a --- /dev/null +++ b/.github/actions/codesign-obs/setup-macos/README.md @@ -0,0 +1,62 @@ +# setup-macos Action + +The setup-macos action sets up an Apple Developer certificate in the key-chain of a macOS GitHub Actions runner for use with code signing and also sets up a provisioning profile required for system extensions support used by OBS Studio. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `identity` | The Apple Developer ID associated with the developer certificate that will be installed in the GitHub Actions runner's key-chain. | (Required) | +| `cert` | The Apple Developer PKCS12 certificate as a base64-encoded string. | (Required) | +| `cert-password` | The password required to unlock the PKCS12 certificate. | (Required) | +| `provisioning-profile` | The provisioning profile as a base64-encoded string. | `''` | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `can-codesign` | A boolean string to indicate whether the provided inputs enable code signing on the GitHub Actions runner. | +| `identity` | The Apple Developer ID for which a code signing certificate was successfully installed. | +| `team` | The Apple Developer team for which a code signing certificate was successfully installed. | +| `profile` | A boolean string to indicate whether a provisioning profile was installed on the GitHub Actions runner. | +| `profile-uuid` | The UUID of the provisioning profile that was installed on the GitHub Actions runner. | + +## Common Usage + +The inputs provided to the action should commonly be stored as secrets and need to be provided as inputs to the action. + +```yaml +jobs: + code-sign-macos: + name: Code Sign macOS Disk Image + runs-on: macos-26 + environment: + name: code-signing + deployment: false + steps: + - name: Set Up Code Signing + id: setup + uses: ./.github/actions/codesign-obs/setup-macos + with: + identity: ${{ secrets.macos-developer-id }} + cert: ${{ secrets.macos-developer-cert }} + cert-password: ${{ secrets.macos-developer-cert-pass }} +``` + +## Notes + +> [!IMPORTANT] +> The action requires a macOS GitHub Actions runner. + +Code signing certificates need to be installed in the macOS key-chain to be available for `codesign` and identified by the Apple Developer team (or Apple Developer ID). By default macOS will also require a password when exporting the developer certificate and key, which should use a random password that is only used by the GitHub Actions runner. + +> [!WARNING] +> Secrets should always be stored as environment secrets (and not repository secrets) as this allows a project to require approval by organization members before an associated workflow actually executes and accesses these secrets. + +## Developer Notes + +As the certificate is a base64-encoded string, the value will be decoded first before being dumped into a temporary file on the GitHub Actions runner's disk and imported into a new temporary key-chain. This key-chain is then made available for Apple's command-line tools which avoids having to interact with the system key-chain. + +The temporary key-chain uses a random password but is also unlocked automatically by the action and thus is not shared with the workflow. diff --git a/.github/actions/codesign-obs/setup-macos/action.yaml b/.github/actions/codesign-obs/setup-macos/action.yaml new file mode 100644 index 00000000000000..a14d9dcbb7cc8b --- /dev/null +++ b/.github/actions/codesign-obs/setup-macos/action.yaml @@ -0,0 +1,61 @@ +name: Set Up macOS Code Signing +description: > + Sets up an Apple Developer certificate in the key-chain of a macOS runner for use with code signing and also sets up + a provisioning profile required for system extensions support used by OBS Studio. +inputs: + identity: + description: > + The Apple Developer ID associated with the developer certificate that will be installed in the runner's key-chain. + required: true + cert: + description: The Apple Developer PKCS12 certificate as a base64-encoded string. + required: true + cert-password: + description: The password required to unlock the PKCS12 certificate. + required: true + provisioning-profile: + description: The provisioning profile as a base64-encoded string. + default: '' +outputs: + can-codesign: + description: A boolean string indicating whether the provided inputs enable code signing on the runner. + value: ${{ steps.codesign.outputs.have-codesign-ident }} + identity: + description: The Apple Developer ID for which a code signing certificate was successfully installed. + value: ${{ steps.codesign.outputs.codesign-ident }} + team: + description: The Apple Developer team for which a code signing certificate was successfully installed. + value: ${{ steps.codesign.outputs.codesign-team }} + profile: + description: A boolean string indicating whether a provisioning profile was installed on the runner. + value: ${{ steps.provisioning.outputs.have-rovisioning-profile }} + profile-uuid: + description: The UUID of the provisioning profile that was installed on the runner. + value: ${{ steps.provisioning.outputs.profile-uuid }} +runs: + using: composite + steps: + - name: Check Runner Operating System + if: runner.os != 'macOS' + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + run: | + : Check Runner Operating System + echo '::error::codesign-obs/setup-macos action requires a macOS runner.' + exit 1 + + - name: Set Up Key-Chain + id: codesign + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + SIGNING_IDENTITY: ${{ inputs.identity }} + SIGNING_CERT: ${{ inputs.cert }} + SIGNING_CERT_PASSWORD: ${{ inputs.cert-password }} + run: zsh --errexit --pipefail --no-rcs ${GITHUB_ACTION_PATH}/setup-keychain.zsh + + - name: Set Up Provisioning Profile + id: provisioning + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + CODESIGN_TEAM: ${{ steps.codesign.outputs.codesign-team }} + PROVISIONING_PROFILE: ${{ inputs.provisioning-profile }} + run: zsh --errexit --pipefail --no-rcs ${GITHUB_ACTION_PATH}/setup-provisioning.zsh diff --git a/.github/actions/codesign-obs/setup-macos/setup-keychain.zsh b/.github/actions/codesign-obs/setup-macos/setup-keychain.zsh new file mode 100755 index 00000000000000..7d09f954369edb --- /dev/null +++ b/.github/actions/codesign-obs/setup-macos/setup-keychain.zsh @@ -0,0 +1,79 @@ +#!/usr/bin/env zsh + +builtin emulate -L zsh +setopt PUSHD_SILENT +setopt EXTENDED_GLOB +setopt ERR_EXIT +setopt ERR_RETURN +setopt NO_UNSET +setopt PIPE_FAIL +setopt NO_AUTO_PUSHD +setopt NO_PUSHD_IGNORE_DUPS +setopt NO_GLOB_SUBST +setopt WARN_CREATE_GLOBAL +setopt WARN_NESTED_VAR + +: ${CI:?} +if (( ${+RUNNER_DEBUG} )) setopt XTRACE + +setup-keychain() { + if [[ -n "${SIGNING_IDENTITY}" && \ + -n "${SIGNING_CERT}" && \ + -n "${SIGNING_CERT_PASSWORD}" ]] \ + { + + local certificate_path="${RUNNER_TEMP}/build_certificate.p12" + local keychain_path="${RUNNER_TEMP}/app-signing.keychain-db" + + base64 --decode --output=${certificate_path} <<< "${SIGNING_CERT}" + + print '::group::Keychain setup' + local keychain_password + function { + setopt NOXTRACE + keychain_password="$(head --bytes=32 /dev/urandom | xxd -p -cols 0)" + print "::add-mask::${keychain_password}" + } + + security create-keychain -p ${keychain_password} ${keychain_path} + security set-keychain-settings -lut 21600 ${keychain_path} + security unlock-keychain -p ${keychain_password} ${keychain_path} + + security import ${certificate_path} \ + -P ${SIGNING_CERT_PASSWORD} \ + -A \ + -t cert \ + -f pkcs12 \ + -k ${keychain_path} \ + -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcrun + + security set-key-partition-list \ + -S 'apple-tool:,apple:' \ + -k ${keychain_password} \ + ${keychain_path} &> /dev/null + + security list-keychain \ + -d user \ + -s ${keychain_path} \ + login-keychain + print '::endgroup::' + + local team_id="${${SIGNING_IDENTITY##*\(}%%\)*}" + print "::add-mask::${team_id}" + + rm ${certificate_path} + + { + print 'have-codesign-ident=true' + print "codesign-ident=${SIGNING_IDENTITY}" + print "keychain-password=${keychain_password}" + print "codesign-team=${team_id}" + } >> ${GITHUB_OUTPUT} + print 'Code signing Apple Developer certificate set up on runner.' + } else { + print 'have-codesign-ident=false' >> ${GITHUB_OUTPUT} + print 'No code signing identity provided. No certificate was set up.' + } +} + +setup-keychain diff --git a/.github/actions/codesign-obs/setup-macos/setup-provisioning.zsh b/.github/actions/codesign-obs/setup-macos/setup-provisioning.zsh new file mode 100755 index 00000000000000..167ba3adcadd6a --- /dev/null +++ b/.github/actions/codesign-obs/setup-macos/setup-provisioning.zsh @@ -0,0 +1,56 @@ +#!/usr/bin/env zsh + +builtin emulate -L zsh +setopt PUSHD_SILENT +setopt EXTENDED_GLOB +setopt ERR_EXIT +setopt ERR_RETURN +setopt NO_UNSET +setopt PIPE_FAIL +setopt NO_AUTO_PUSHD +setopt NO_PUSHD_IGNORE_DUPS +setopt NO_GLOB_SUBST +setopt WARN_CREATE_GLOBAL +setopt WARN_NESTED_VAR + +: ${CI:?} +if (( ${+RUNNER_DEBUG} )) setopt XTRACE + +setup-provisioning-profile() { + if [[ -n "${PROVISIONING_PROFILE}" ]] { + + local profile_path="${RUNNER_TEMP}/build_profile.provisionprofile" + base64 --decode --output=${profile_path} <<< "${PROVISIONING_PROFILE}" + + mkdir -p "${HOME}/Library/MobileDevice/Provisioning Profiles" + security cms \ + -D \ + -i ${profile_path} \ + -o ${RUNNER_TEMP}/build_profile.plist + + local uuid + uuid="$(plutil -extract UUID raw ${RUNNER_TEMP}/build_profile.plist)" + print "::add-mask::${uuid}" + + local team_id + team_id="$(plutil -extract TeamIdentifier.0 raw -expect string ${RUNNER_TEMP}/build_profile.plist)" + print "::add-mask::${team_id}" + + if [[ ${team_id} != "${CODESIGN_TEAM:-}" ]] { + print '::notice::Code Signing team in provisioning profile does not match certificate.' + } + + cp ${profile_path} "${HOME}/Library/MobileDevice/Provisioning Profiles/${uuid}.provisionprofile" + + { + print 'have-provisioning-profile=true' + print "profile-uuid=${uuid}" + } >> ${GITHUB_OUTPUT} + print 'Provisioning profile found and installed on runner.' + } else { + print 'have-provisioning-profile=false' >> ${GITHUB_OUTPUT} + print 'No provisioning profile provided.' + } +} + +setup-provisioning-profile diff --git a/.github/actions/codesign-obs/setup-windows/README.md b/.github/actions/codesign-obs/setup-windows/README.md new file mode 100644 index 00000000000000..72832f30219430 --- /dev/null +++ b/.github/actions/codesign-obs/setup-windows/README.md @@ -0,0 +1,61 @@ +# setup-windows Action + +The setup-windows action sets up Google CNG provider and authenticates with the Google Cloud to create an authenticated environment on a Windows GitHub Actions runner. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `gcp-identity-provider` | The full identifier of the Workload Identity Provider, including the project number, pool name, and provider name. | `REQUIRED` | +| `gcp-account-name` | Email address or unique identifier of the Google Cloud service account. | `REQUIRED` | +| `github-token` | The GitHub token required for the `gh` command-line utility to download the Google CNG tool. | `github.token` | + +### Outputs + +This action has no outputs. + +## Common Usage + +The inputs provided to the action should commonly be stored as secrets and need to be provided as inputs to the action. + +```yaml +jobs: + code-sign-windows: + name: Code Sign Windows Build + runs-on: windows-2025-vs2026 + environment: + name: code-signing + deployment: false + steps: + - name: Set Up Code Signing + uses: ./.github/actions/codesign-obs/setup-windows + with: + gcp-identity-provider: ${{ secrets.gcp-identity-string }} + gcp-account-name: ${{ secrets.gcp-account-name }} +``` + +## Notes + +> [!IMPORTANT] +> The action requires a Windows GitHub Actions runner. + +The `gcp-identity-provider` indeed needs to be the full string as documented by Google, e.g.: + +``` +projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider +``` + +The `gcp-account-name` is the email address used for the associated Google account e.g., `my-service-account@my-project.iam.gserviceaccount.com`. + +> [!WARNING] +> Secrets should always be stored as environment secrets (and not repository secrets) as this allows a project to require approval by organisation members before an associated workflow actually executes and accesses these secrets. + +## Developer Notes + +The action automatically downloads and installs Google's Cloud CNG Provider from https://github.com/GoogleCloudPlatform/kms-integrations and runs the https://github.com/google-github-actions/auth action to authenticate with the provided credentials. + +This effectively puts the GitHub Actions runner into an "authenticated" state, such that when `signtool.exe` uses the Google CNG provider with the certificate singing request it detects and uses the authentication set up by this action. + +* File paths are handled in their UNIX variant and converted from and to Windows format at the input/output edge of the Bash script. diff --git a/.github/actions/codesign-obs/setup-windows/action.yaml b/.github/actions/codesign-obs/setup-windows/action.yaml new file mode 100644 index 00000000000000..99ccdc376940b3 --- /dev/null +++ b/.github/actions/codesign-obs/setup-windows/action.yaml @@ -0,0 +1,37 @@ +name: Setup Windows Code Signing +description: > + Sets up Google CNG provider and authenticates with the Google Cloud to create an authenticated environment on a + Windows runner. +inputs: + gcp-identity-provider: + description: > + The full identifier of the Workload Identity Provider, including the project number, pool name, and provider name. + required: true + gcp-account-name: + description: Email address or unique identifier of the Google Cloud service account. + required: true + github-token: + description: The GitHub token required for the 'gh' command-line utility to download the Google CNG tool. + default: ${{ github.token }} +runs: + using: composite + steps: + - name: Check Runner + uses: ./.github/actions/check-runner + with: + os: Windows + custom-error: codesign-obs/setup-windows action requires a Windows runner. + + - name: Setup Google CNG Provider + id: setup-gcp + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + CNG_VERSION: '1.4' + GH_TOKEN: ${{ inputs.github-token }} + run: ${GITHUB_ACTION_PATH}/setup-cng.bash + + - name: 'Authenticate to Google Cloud' + uses: 'google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093' # v3.0.0 + with: + workload_identity_provider: ${{ inputs.gcp-identity-provider }} + service_account: ${{ inputs.gcp-account-name }} diff --git a/.github/actions/windows-signing/cng-release-signing-key.pem b/.github/actions/codesign-obs/setup-windows/cng-release-signing-key.pem similarity index 100% rename from .github/actions/windows-signing/cng-release-signing-key.pem rename to .github/actions/codesign-obs/setup-windows/cng-release-signing-key.pem diff --git a/.github/actions/codesign-obs/setup-windows/setup-cng.bash b/.github/actions/codesign-obs/setup-windows/setup-cng.bash new file mode 100755 index 00000000000000..25befb0e70facb --- /dev/null +++ b/.github/actions/codesign-obs/setup-windows/setup-cng.bash @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +setup-cng() { + mkdir -p "${RUNNER_TEMP}/google-cng" + local repo_name="GoogleCloudPlatform/kms-integrations" + + cd "${RUNNER_TEMP}/google-cng" + + gh release download "cng-v${CNG_VERSION}" --repo "${repo_name}" --pattern "*amd64.zip" + + local release_name="kmscng-${CNG_VERSION}-windows-amd64.zip" + + if [[ ! -r "${release_name}" ]]; then + echo "::error::No CNG provider release found - expected '${release_name}'." + return 1 + fi + + unzip -j "${release_name}" + + local -a openssl_arguments=( + -sha384 + --verify + "${GITHUB_ACTION_PATH}/cng-release-signing-key.pem" + -signature kmscng.msi.sig + ) + + openssl dgst "${openssl_arguments[@]}" kmscng.msi + msiexec //qn //norestart //i kmscng.msi + + local windows_path + windows_path="$(cygpath --windows "${RUNNER_TEMP}/google-cng")" + echo "google-cng-path=${windows_path}" >> "${GITHUB_OUTPUT}" +} + +setup-cng diff --git a/.github/actions/compatibility-validator/action.yaml b/.github/actions/compatibility-validator/action.yaml deleted file mode 100644 index c6c46074810343..00000000000000 --- a/.github/actions/compatibility-validator/action.yaml +++ /dev/null @@ -1,64 +0,0 @@ -name: Compatibility Data Validator -description: Checks Windows compatibility data files -inputs: - repositorySecret: - description: GitHub token for API access - required: true - workingDirectory: - description: Working directory for checks - required: false - default: ${{ github.workspace }} -runs: - using: composite - steps: - - name: Check Runner Operating System đŸƒâ€â™‚ī¸ - if: runner.os == 'Windows' - shell: bash - run: | - : Check Runner Operating System đŸƒâ€â™‚ī¸ - echo "services-validation action requires a macOS-based or Linux-based runner." - exit 2 - - - name: Install and Configure Python 🐍 - shell: bash - run: | - : Install and Configure Python 🐍 - if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi - - echo ::group::Python Set Up - if [[ "${RUNNER_OS}" == Linux ]]; then - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" - echo "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin" >> $GITHUB_PATH - fi - brew update - brew install --quiet python3 - - python3 -m venv .venv - - source .venv/bin/activate - python3 -m pip install jsonschema json_source_map - echo ::endgroup:: - - - name: Validate Compatibility Files JSON Schema đŸ•ĩī¸ - shell: bash - working-directory: ${{ inputs.workingDirectory }} - run: | - : Validate services file JSON schema đŸ•ĩī¸ - if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi - shopt -s extglob - - echo ::group::Schema Validation - source .venv/bin/activate - python3 -u \ - .github/scripts/utils.py/check-jsonschema.py \ - --loglevel INFO \ - plugins/win-capture/data/@(compatibility|package).json - echo ::endgroup:: - - - name: Annotate Schema Validation Errors đŸˇī¸ - uses: yuzutech/annotations-action@00b2d488bcba3bd01014dc073d276ef4a45d5c6c # v0.6.0 - if: failure() - with: - repo-token: ${{ inputs.repositorySecret }} - title: Compatibility JSON Errors - input: ${{ inputs.workingDirectory }}/validation_errors.json diff --git a/.github/actions/create-pull-request/README.md b/.github/actions/create-pull-request/README.md new file mode 100644 index 00000000000000..8eaa62c64c5582 --- /dev/null +++ b/.github/actions/create-pull-request/README.md @@ -0,0 +1,99 @@ +# create-pull-request + +The create-pull-request action creates a local branch with all local changes in the git repository identified by the `working-directory` and automatically rebases or resets the branch depending on the remote state of the head branch. + +When that branch is successfully created, a GitHub pull request is either created or updated. + +If the head branch is deleted, the associated pull request will be automatically closed. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `base-ref` | The git reference to locally base the changes on. | `''` | +| `author` | The name and email address to use as the author of the local changes. | (GitHub Actor) [^1] | +| `committer` | The name and email address to use as the committer of the local changes. | (GitHub Actions Bot) [^2] | +| `branch` | The source branch name to use for the pull request. | `ci-pull-request` | +| `commit-message` | The commit message to use for local changes. | `DEFAULT`[^3] | +| `title` | The pull request title text. | `DEFAULT`[^4] | +| `body` | The pull request body text. | `DEFAULT`[^5] | +| `delete-unused` | An optional boolean value to indiciate whether the branch should be deleted if there are no changes. | `false` | +| `github-token` | The GitHub token required for the `gh` command-line utility to create or edit pull requests. The provided token needs to have the `pull-requests: write` permission. | `github.token` | +| `working-directory` | The path from which to run all git operations. | `github.workspace` | + +[^1]: `ActorName ` +[^2]: `github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com` +[^3]: "CI: Automatically generated changes." +[^4]: "CI: Automatically generated changes by create-pull-request action." +[^5]: "These changes have been automatically generated by CI." + +### Outputs + +| Output | Description | +|:------:|-------------| +| `pull-request-result` | A string value to indicate whether a pull requests was created, opened, or closed. | +| `pull-request-number` | The pull request number if one was created or updated. | +| `pull-request-url` | The pull request URL if one was created or updated. | + +## Common Usage + +The action expects a "dirty" checkout with either the currently checked-out `HEAD` as the bases for changes or the provided `base-ref`, but has no further prerequisites. + + +```yaml + - name: Create Pull Request + uses: ./.github/actions/create-pull-request + with: + author: 'GitHub CI ' + branch: 'ci-pull-requests' + commit-message: 'Automated Changes' + title: 'CI: Automatic Changes' + body: > + This is a multiline body for the pull request body. + + If the pull request already exists, its title and body will be replaced with each invocation + of the action. + delete-unused: true + github-token: ${{ github.token }} +``` + +## Notes + +The distinction between the `base-ref` and the `branch` is that the former identifies the reference (or branch) which the local changes will be rebased on, whereas the latter identifies to branch that the changes will be pushed onto. + +This is potentially necessary if the changes are made based on the main branch but need to be based on another branch e.g., because the https://github.com/actions/checkout action only checked out the main branch before changes were made. + +The action will handle all this internally but if there is no way to properly rebase the local branch onto the target branch, **the target branch will be reset** to the local variation of the branch. + +## Developer Notes + +This action encapsulates native `git` commands to ensure that the local changes are based on the correct base reference and also ensures that the necessary credentials are set up in case that the checkout has been made without persisting credentials (which is desired from a security POV). + +The rough order of actions is: + +* Detect the local checkout type and use the provided GitHub token to set up HTTPs authentication if necessary. +* Check if the local changes where made on a branch or a detached HEAD. The base reference cannot be identical to the target branch. +* Create a local temporary branch with a random UUID and add all local changes as commits to this branch. +* If the local reference is a branch, reset it to the remote state of the same branch. +* If the base reference is different from the local reference, fetch the base reference from the remote and switch the checkout to it. + * Identify all commits on the local temporary branch not present on the base reference, then cherry pick them. + * Then reset the local temporary branch to the new HEAD based on the base reference with the cherry-picked commits. + * Finally, reset the base reference back to the state on the remote. + +At this point there will be a local temporary branch that is either based on the local reference or based on the specified base reference. Next the compatibility of the local temporary branch with the target branch on the remote needs to be checked: + +* Fetch the target branch from the remote based on the amount of changes between it and the local temporary branch. +* If the target branch does not already exist, simply create it based on the local temporary branch. +* If it does exist, create a local checkout first. + * Next, check if the target branch is actually ahead of the base reference. + * The target branch will be reset to the local temporary branch under a specific set of circumstances: + * If the target branch has changes that do not exist on the temporary branch (which suggests that they were never merged into the base reference and suggests the branch has never been merged via a pull request). + * If the branches have no diverting change-set (the same files were changed), but the amount of commits that encapsulate those changes differ. + * If the temporary branch is actually behind the target branch. + * If the branches have the same amount of commits and no different changed file list, but the actual changes on the target branch differ from the changes on the local temporary branch. + +By the end of this process a local branch with the name of the target branch exists and contains all local changes based on the base reference and - if possible - rebased onto the target reference. The local temporary branch is not needed anymore and is deleted. + +Finally this branch is force-pushed to the remote and the `gh` command-line utility used to create or update a pull request based on the author, source branch ,and target branch, which uniquely identify a GitHub pull request. diff --git a/.github/actions/create-pull-request/action.yaml b/.github/actions/create-pull-request/action.yaml new file mode 100644 index 00000000000000..d1171704009dc9 --- /dev/null +++ b/.github/actions/create-pull-request/action.yaml @@ -0,0 +1,80 @@ +name: Create Pull Request +description: Creates a GitHub pull request based on all changes in local git checkout. +inputs: + base-ref: + description: The git reference to locally base the changes on. + default: '' + author: + description: The name and email address to use as the author of the local changes. + default: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com> + committer: + description: The name and email address to use as the committer of the local changes. + default: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + branch: + description: The source branch name to use for the pull request. + default: ci-pull-request + commit-message: + description: The commit message to use for local changes. + default: > + CI: Automatically generated changes + title: + description: The pull request title text. + default: > + CI: Automatically generated changes by create-pullrequest action. + body: + description: The pull request body text. + default: > + These changes have been automatically generated by CI. + delete-unused: + description: An optional boolean value to indiciate whether the branch should be deleted if there are no changes. + default: 'false' + github-token: + description: > + The GitHub token required for the 'gh' command-line utility to create or edit pull requests. The provided token + needs to have the 'pull-requests: write' permission. + default: ${{ github.token }} + working-directory: + description: The path from which to run all git operations. + default: ${{ github.workspace }} +outputs: + pull-request-result: + value: ${{ steps.create.outputs.pull-request-result }} + description: A string value to indicate whether a pull requests was created, opened, or closed. + pull-request-number: + value: ${{ steps.create.outputs.pull-request-number }} + description: The pull request number if one was created or updated. + pull-request-url: + value: ${{ steps.create.outputs.pull-request-url }} + description: The pull request URL if one was created or updated. +runs: + using: composite + steps: + - name: Prepare Changes + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.working-directory }} + id: prepare + env: + GH_TOKEN: ${{ inputs.github-token }} + BASE_REF: ${{ inputs.base-ref }} + BRANCH: ${{ inputs.branch }} + AUTHOR: ${{ inputs.author }} + COMMITTER: ${{ inputs.committer }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + run: ${GITHUB_ACTION_PATH}/prepare-changes.bash + + - name: Create Pull Request + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.working-directory }} + id: create + env: + GH_TOKEN: ${{ inputs.github-token }} + OUTCOME: ${{ steps.prepare.outputs.outcome }} + BASE_REF_SHA: ${{ steps.prepare.outputs.base-ref-sha }} + HEAD_REF_SHA: ${{ steps.prepare.outputs.head-ref-sha }} + DEVIATED: ${{ steps.prepare.outputs.deviated-from-base }} + BRANCH: ${{ inputs.branch }} + TITLE: ${{ inputs.title }} + BASE_REF: ${{ inputs.base-ref }} + BODY: ${{ inputs.body }} + DELETE_UNUSED: ${{ inputs.delete-unused }} + run: ${GITHUB_ACTION_PATH}/create-pull-request.bash diff --git a/.github/actions/create-pull-request/create-pull-request.bash b/.github/actions/create-pull-request/create-pull-request.bash new file mode 100755 index 00000000000000..0ab8fc2d9f1cb7 --- /dev/null +++ b/.github/actions/create-pull-request/create-pull-request.bash @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +shopt -s extglob + +check-work-base() { + if ! work_base_ref="$(git symbolic-ref --quiet --short HEAD)"; then + if [[ -z "${BASE_REF}" ]]; then + echo '::error::No source ref provided with checkout in detached HEAD mode.' + return 1 + fi + + work_base_ref="$(git rev-parse HEAD)" + fi +} + +create-pull-request() { + local work_base_ref + check-work-base + + local base_ref="${BASE_REF:-"${work_base_ref}"}" + + # Target branch is either a new branch or deviates from existing target branch. + # Force push the target branch to the remote. + if [[ "${OUTCOME}" == @(new|deviated) ]]; then + git push --force-with-lease origin "${BRANCH}:refs/heads/${BRANCH}" + fi + + local pull_request_result='fail' + + # PR branch actually deviates from target branch, pull request should be created. + if [[ "${DEVIATED:-false}" == 'true' ]]; then + echo -e "${BODY}" > "${RUNNER_TEMP}/pr_body.txt" + + local pull_request_url='' + pull_request_url="$(gh pr list \ + --base "${base_ref}" \ + --head "${BRANCH}" \ + --limit 1 \ + --json url --jq '.[].url')" + + if [[ -n "${pull_request_url}" ]]; then + # Existing pull request found, update content. + gh pr edit "${pull_request_url##*\/}" \ + --base "${base_ref}" \ + --title "${TITLE}" \ + --body-file "${RUNNER_TEMP}/pr_body.txt" + + pull_request_result='updated' + else + # No existing pull request found, create new one. + local -i failed=0 + + if ! gh pr create \ + --base "${base_ref}" \ + --head "${BRANCH}" \ + --title "${TITLE}" \ + --body-file "${RUNNER_TEMP}/pr_body.txt"; then + failed=1 + fi + + if (( ! failed )); then + # Pull request creation suceeded, get pull request URL. + pull_request_url="$(gh pr list \ + --base "${base_ref}" \ + --head "${BRANCH}" \ + --limit 1 \ + --json url --jq '.[].url')" + + pull_request_result='created' + else + echo '::error::Unable to create or edit pull request.' + return 1 + fi + fi + + { + echo "pull-request-result=${pull_request_result}" + echo "pull-request-number=${pull_request_url##*\/}" + echo "pull-request-url=${pull_request_url}" + } >> "${GITHUB_OUTPUT}" + else + # If there are no deviations from the base branch, delete the target branch if requested. + if [[ "${OUTCOME}" == @(even|deviated) ]]; then + if [[ "${DELETE_UNUSED:-false}" == 'true' ]]; then + git push --delete --force origin "refs/heads/${BRANCH}" + echo 'pull-request-result=closed' >> "${GITHUB_OUTPUT}" + fi + fi + fi +} + +create-pull-request diff --git a/.github/actions/create-pull-request/prepare-changes.bash b/.github/actions/create-pull-request/prepare-changes.bash new file mode 100755 index 00000000000000..9832754c563b61 --- /dev/null +++ b/.github/actions/create-pull-request/prepare-changes.bash @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +check-git-remote() { + local remote_url + remote_url="$(git config --local --get 'remote.origin.url')" + + local https_pattern='^https:\/\/([^\/]+)\/([^\/]+)\/(.+(\.git)?)$' + if [[ "${remote_url}" =~ ${https_pattern} ]]; then + hostname="${BASH_REMATCH[1]}" + repository="${BASH_REMATCH[2]}/${BASH_REMATCH[3]}" + protocol='https' + return + fi + + local ssh_pattern='git@([^:]+):(.+)\.git$' + if [[ "${remote_url}" =~ ${ssh_pattern} ]]; then + hostname="${BASH_REMATCH[1]}" + repository="${BASH_REMATCH[2]}" + protocol='ssh' + fi +} + +check-work-base() { + if work_base_ref="$(git symbolic-ref --quiet --short HEAD)"; then + work_base_type='branch' + else + if [[ -z "${BASE_REF}" ]]; then + echo '::error::No source ref provided with checkout in detached HEAD mode.' + return 1 + fi + + work_base_ref="$(git rev-parse HEAD)" + work_base_type='commit' + fi +} + +setup-target-branch() { + # Calculate fetch depth with some buffer based on number of new commits ahead of the PR branch + local -i fetch_depth=10 + local commits_ahead + commits_ahead="$(git rev-list --right-only --count "${base_ref}...${uuid}")" + + if (( commits_ahead )); then + fetch_depth+="${commits_ahead}" + fi + + # Try to fetch the target branch name from remote + if ! git fetch \ + --no-tags \ + --no-recurse-submodules \ + --force \ + --depth="${fetch_depth}" \ + origin "${target_ref}:refs/remotes/origin/${target_ref}" &> /dev/null; \ + then + # Target branch does not exist on remote, must be created locally first. + # Use the temporary branch (by now either based off the main branch or the PR branch). + git checkout -B "${target_ref}" "${uuid}" -- + + # Check if the new target branch is actually ahead of the PR branch. + commits_ahead="$(git rev-list --right-only --count "${base_ref}..${target_ref}")" + + if (( commits_ahead )); then + outcome='new' + deviated_from_base=1 + fi + else + # Target branch exists on remote, create a local checkout first. + git checkout "${target_ref}" -- + + # Check if the target branch is actually ahead of the local PR branch + local target_branch_ahead + target_branch_ahead="$(git rev-list --right-only --count "${base_ref}..${target_ref}")" + + # Logic adapted from https://github.com/peter-evans/create-pull-request + # The target branch will be reset under these conditions: + # * The target branch has changes not contained in the temporary branch + # * If the branches have no diverting change-set, but the amount of commits differs + # (e.g. the same changes now encompass 5 instead of 7 commits). + # * If the temporary branch is behind the target branch + # * Finally, if the branch has the same amount of commits, and no superficial changes, + # compare the actual change-set. If the changes are not equal, the base has deviated + # from the target. + if ! git diff --quiet "${target_ref}..${uuid}" \ + || (( target_branch_ahead != commits_ahead )) \ + || (( commits_ahead <= 0 )); then + local diff_target + local diff_temp + + if diff_target="$(git diff --stat "${target_ref}..${target_ref}~${commits_ahead}" -- 2> /dev/null)" \ + && diff_temp="$(git diff --stat "${uuid}..${uuid}~${commits_ahead}" -- 2> /dev/null)"; then + if [[ "${diff_target}" != "${diff_temp}" ]]; then + # Reset target branch to temporary branch. + git checkout -B "${target_ref}" "${uuid}" -- + fi + fi + fi + + # Check if local target branch deviates from remote. + local target_ahead + local target_behind + target_ahead="$(git rev-list --right-only --count "origin/${target_ref}..${target_ref}")" + target_behind="$(git rev-list --left-only --count "origin/${target_ref}..${target_ref}")" + + if (( target_ahead == 0 && target_behind == 0 )); then + outcome='even' + else + outcome='deviated' + fi + + # Check if target branch is still ahead of base branch + commits_ahead="$(git rev-list --right-only --count "${base_ref}..${target_ref}")" + + if (( commits_ahead )); then + deviated_from_base=1 + fi + fi +} + +setup-pull-branch() { + local uuid + uuid="$(uuidgen)" + + # Create temporary branch for local changes + git checkout -B "${uuid}" HEAD -- + + # Check if there are any uncommitted changes + local changes + changes="$(git status --porcelain --untracked-files=normal --)" + + # Commit all changes + if [[ -n "${changes}" ]]; then + git add --all + git \ + -c "author.name=${AUTHOR% *}" \ + -c "author.email=${AUTHOR##* }" \ + -c "committer.name=${COMMITTER%% *}" \ + -c "committer.email=${COMMITTER##* }" \ + commit \ + --message="${COMMIT_MESSAGE}" + fi + + # Stash anything else + local -i has_stash=0 + local stash_result + stash_result="$(LC_ALL=C git stash push --include-untracked)" + if [[ "${stash_result}" != 'No local changes to save' ]]; then + has_stash=1 + fi + + # Reset current working base branch + if [[ "${work_base_type}" == 'branch' ]]; then + git checkout "${work_base_ref}" -- + git reset --hard "origin/${work_base_ref}" + fi + + # If PR branch should not be based on working base branch (e.g. not on the main branch), + # rebase the temporary branch on the PR branch. + if [[ "${work_base_ref}" != "${base_ref}" ]]; then + # Check out the PR branch as base branch + git fetch --no-tags --no-recurse-submodules --force --depth=1 origin "${base_ref}:${base_ref}" + git checkout "${base_ref}" -- + + # Get all changes between working base branch and temporary branch + local commits + commits="$(git rev-list --reverse "${work_base_ref}..${uuid}" .)" + + # Cherry-pick all changes + local commit + while read -r commit; do + git cherry-pick --strategy=recursive --strategy-option=theirs "${commit}" + done <<< "${commits}" + + # Reset temporary branch to new HEAD based on the PR branch + git checkout -B "${uuid}" HEAD -- + + # Reset the PR branch + git fetch --no-tags --no-recurse-submodules --force --depth=1 origin "${base_ref}:${base_ref}" + fi + + setup-target-branch + + base_ref_sha="$(git rev-parse "${base_ref}")" + head_ref_sha="$(git rev-parse "${target_ref}")" + + # Clean up the temporary branch + git branch --delete --force "${uuid}" + + # Check out working base directory to reset git state + git checkout "${work_base_ref}" + + if (( has_stash )); then + git stash pop + fi +} + + +prepare-changes() { + if [[ "${RUNNER_OS}" == 'Linux' ]] && ! command -v uuidgen > /dev/null; then + sudo apt-get update + sudo apt-get install uuid-runtime + fi + + local hostname + local protocol + local repository + check-git-remote + + : "${repository}" + + if [[ "${protocol}" == 'https' ]]; then + local base64_string + { + set +x > /dev/null + base64_string="$(printf '%s' "x-access-token:${GH_TOKEN}" | base64 -w 0)" + echo "::add-mask::${base64_string}" + + if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + } + git config --local "http.https://${hostname}/.extraheader" "AUTHORIZATION: basic ${base64_string}" + fi + + local work_base_ref + local work_base_type + check-work-base + + local base_ref="${BASE_REF:-"${work_base_ref}"}" + local target_ref="${BRANCH}" + + if [[ "${base_ref}" == "${target_ref}" ]]; then + echo '::error::Base branch and target branch cannot be identical.' + return 1 + fi + + local outcome='none' + local -i deviated_from_base=0 + local base_ref_sha + local head_ref_sha + setup-pull-branch + + { + echo "outcome=${outcome}" + echo "base-ref-sha=${base_ref_sha}" + echo "head-ref-sha=${head_ref_sha}" + + if (( deviated_from_base )); then + echo 'deviated-from-base=true' + else + echo 'deviated-from-base=false' + fi + } >> "${GITHUB_OUTPUT}" +} + +prepare-changes diff --git a/.github/actions/download-asset/README.md b/.github/actions/download-asset/README.md new file mode 100644 index 00000000000000..e614c34ea51af2 --- /dev/null +++ b/.github/actions/download-asset/README.md @@ -0,0 +1,70 @@ +# download-asset + +The download-asset action downloads an OBS Studio workflow asset based on the platform and architecture and also the GitHub event that triggered the workflow that implements the action. + +This simplifies the different ways the assets need to be downloaded depending on whether the download URL is available as part of the GitHub event data, is available as part of the same workflow run (in case of workflow artifacts) or need to be fetched based on a semantic version tag for an existing GitHub release. + +## Documentation + +### Inputs + +| Input | Description | Default | +|:-----:|-------------|---------| +| `platform` | The platform name for which to download the asset. Available values are `Windows`, `macOS`, or an Ubuntu GitHub Actions runner identifier. | `REQUIRED` | +| `architecture` | The build architecture for which to download the asset. Available values are `arm64` or `x86_64`. | `REQUIRED` | +| `custom-asset` | A download URL for a custom asset to download. This asset needs to replace an existing asset identified by the platform and architecture inputs. | `''` | +| `suffix` | The action will automatically generate a possible workflow artifact name based on platform and architecture. The suffix can be added to more uniquely identify an asset. (`push` and `schedule` events only). | `''` | +| `verify` | A boolean value to indicate whether the downloaded asset should be verified using GitHub Actions attestations. | `false` | +| `path` | A path to a directory into which the downloaded assets should be moved. | `github.workspace` | +| `github-token` | The GitHub token required for the `gh` command-line utility to download the assets. | `github.token` | +| `working-directory` | The path to an OBS Studio checkout. This is necessary for the `gh` command-line tool to operate correctly. | `github.workspace` | + +### Outputs + +| Output | Description | +|:------:|-------------| +| `path` | The path to the downloaded asset file. | +| `verified` | A boolean value indicating whether verification succeeded. | + +## Common Usage + +The action only works with specific GitHub workflow events: + +* `release` - the asset will be taken from the release the workflow was triggered for. +* `workflow_dispatch` - the asset will be taken from a release identified by the git ref provided with the dispatch. +* `push` and `schedule` - the asset will be token from the workflow artifacts generated by the current workflow run. + +```yaml + - name: Download Asset + id: download + uses: ./.github/actions/download-asset + with: + platform: macOS + architecture: arm64 + path: ${{ format('{0}/assets', runner.temp) }} +``` + +## Notes + +The target `path` is created if it does not exist, to ensure that if a matching asset was found (either on the release or as a workflow artifact) that it is downloaded and exists at the desired location. + +Using a custom URL has a few requirements: + +* Custom asset URLs are only supported for `workflow_dispatch` workflow runs. +* The download URL needs to contain the actual file name of the downloaded asset. +* If the file name identified by the URL matches an existing asset, it will be replaced. + +> [!NOTE] +> If the custom asset file name does not replace an existing asset, providing the custom asset URL has no effect. A warning will be emitted, but the asset provided by the action will still be the original non-custom asset. + +## Developer Notes + +The action mainly serves as a convenience wrapper around the different ways to fetch assets based on the different events workflows need to operate on existing OBS Studio builds. It uses the `gh` command-line utility for all downloads but with different arguments based on the event type. + +> [!NOTE] +> As `gh` does not support any workflow artifacts uploaded with the `archive: false` option yet, the https://github.com/actions/download-artifact action is used to download artifacts instead. The action will then skip the actual download via `gh` but continue the same otherwise. + +The action is cross-platform and thus uses a Bash script under the hood. As the script uses modern Bash features (mainly associate arrays), a more recent version of Bash is automatically installed on macOS GitHub Actions runners (which only come with Bash v3 by default). + +* File paths are handled in their UNIX variant and converted from and to Windows format at the input/output edge of the Bash script. + diff --git a/.github/actions/download-asset/action.yaml b/.github/actions/download-asset/action.yaml new file mode 100644 index 00000000000000..ccc9c74a47cbc5 --- /dev/null +++ b/.github/actions/download-asset/action.yaml @@ -0,0 +1,135 @@ +name: Download Asset +description: > + Downloads an OBS Studio workflow asset based on the platform and architecture and also the GitHub event that + triggered the workflow that implements the action. +inputs: + platform: + description: > + The platform name for which to download the asset. Available values are 'Windows', 'macOS', or an Ubuntu + runner identifier. + required: true + architecture: + description: > + The build architecture for which to download the asset. Available values are 'arm64' or 'x86_64'. + required: true + custom-asset: + description: > + A download URL for a custom asset to download. This asset needs to replace an existing asset identified by + the platform and architecture inputs. + default: '' + suffix: + description: > + The action will automatically generate a possible workflow artifact name based on platform and architecture. + The suffix can be added to more uniquely identify an asset. ('push' and 'schedule' events only). + default: '' + verify: + description: > + A boolean value to indicate whether the downloaded asset should be verified using GitHub Actions attestations. + default: 'false' + path: + description: A path to a directory into which the downloaded assets should be moved. + default: ${{ github.workspace }} + github-token: + description: The GitHub token required for the 'gh' command-line utility to download the assets. + default: ${{ github.token }} + working-directory: + description: > + The path to an OBS Studio checkout. This is necessary for the 'gh' command-line tool to operate correctly. + default: ${{ github.workspace }} +outputs: + path: + description: The path to the downloaded asset file. + value: ${{ steps.download.outputs.path }} + verified: + description: A boolean value indicating whether verification succeeded. + value: ${{ steps.verify.outputs.verified }} +runs: + using: composite + steps: + - name: Install Bash 5 + id: install-bash + if: runner.os == 'macOS' + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + run: | + # Install Bash + if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + + declare brew_prefix + brew_prefix="$(brew --prefix)" + if [[ ! -x "${brew_prefix}/bin/bash" ]]; then + brew update + brew install bash + fi + + - name: Download Asset Artifacts # Remove once 'gh' supports artifacts uploaded with 'archive: false' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: >- + case( + contains(fromJSON('["push", "schedule"]'), github.event_name), true, + github.event_name == 'workflow_dispatch' && github.ref_type != 'tag', true, + false + ) + with: + path: ${{ inputs.path }} + pattern: >- + ${{ format( + 'obs-studio-{0}-{1}-*{2}{3}', + case( + inputs.platform == 'macOS', 'macos', + inputs.platform == 'Windows', 'windows', + inputs.platform == 'Linux', 'ubuntu', + inputs.platform + ), inputs.architecture, inputs.suffix, + case( + inputs.platform == 'macOS', '.dmg', + inputs.platform == 'Windows', '.zip', + inputs.platform == 'Linux', '.deb', + '') + ) }} + skip-decompress: true + + - name: Download Asset + id: download + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.working-directory }} + env: + GH_TOKEN: ${{ inputs.github-token }} + PLATFORM: ${{ inputs.platform }} + ARCHITECTURE: ${{ inputs.architecture }} + DESTINATION: ${{ inputs.path }} + PATTERN: ${{ inputs.suffix }} + TAG_NAME: ${{ github.ref_name }} + run: ${GITHUB_ACTION_PATH}/download-asset.bash + + - name: Download Custom Asset + id: download-custom + if: github.event_name == 'workflow_dispatch' && inputs.custom-asset != '' + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + working-directory: ${{ inputs.working-directory }} + env: + GH_TOKEN: ${{ inputs.github-token }} + PLATFORM: ${{ inputs.platform }} + ARCHITECTURE: ${{ inputs.architecture }} + CUSTOM_ASSET: ${{ inputs.custom-asset }} + DESTINATION: ${{ inputs.path }} + run: ${GITHUB_ACTION_PATH}/download-custom-asset.bash + + - name: Verify Asset + id: verify + if: fromJSON(inputs.verify) && inputs.custom-asset == '' + shell: bash --noprofile --norc -eo errexit -eo pipefail -eo nounset {0} + env: + GH_TOKEN: ${{ inputs.github-token }} + ASSET_PATH: ${{ steps.download.outputs.path }} + working-directory: ${{ inputs.working-directory }} + run: | + # Verify Asset + if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + + { + if gh attestation verify "${ASSET_PATH}" --owner "${GITHUB_REPOSITORY_OWNER}"; then + echo "verified=true" + else + echo "verified=false" + fi + } >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/download-asset/download-asset.bash b/.github/actions/download-asset/download-asset.bash new file mode 100755 index 00000000000000..a1a953cc796dd5 --- /dev/null +++ b/.github/actions/download-asset/download-asset.bash @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +shopt -s extglob + +download-release-asset() { + download_pattern="${release_patterns["${platform_tuple}"]:-}" + + if [[ -z "${download_pattern}" ]]; then + echo "::error::No download pattern for tuple '${platform_tuple}'." + return 1 + fi + + gh release download "${TAG_NAME}" \ + --pattern "${download_pattern}" \ + --dir "${DESTINATION}" \ + --clobber +} + +download-tag-asset() { + download_pattern="${release_patterns["${platform_tuple}"]:-}" + + if [[ -z "${download_pattern}" ]]; then + echo "::error::No download pattern for tuple '${platform_tuple}'." + return 1 + fi + + if [[ "${TAG_NAME}" =~ [0-9]+\.[0-9]+\.[0-9]+(-(rc|beta)[0-9]+)*$ ]]; then + gh release download "${TAG_NAME}" \ + --pattern "${download_pattern}" \ + --dir "${DESTINATION}" \ + --clobber + fi +} + +download-artifact() { + download_pattern="${artifact_patterns["${platform_tuple}"]:-}" + + if [[ -z "${download_pattern}" ]]; then + echo "::error::No download pattern for tuple '${platform_tuple}'." + return 1 + fi + + # FIXME: Remove early return once 'gh' supports artifacts generated with 'archive: false'. + return + + gh run download "${GITHUB_RUN_ID}" \ + --pattern "${download_pattern}" \ + --dir "${DESTINATION}" +} + +download-assets() { + local -A release_patterns=( + [macos-arm64]="OBS-Studio-*-macOS-Apple${PATTERN:-}.dmg" + [macos-x86_64]="OBS-Studio-*-macOS-Intel${PATTERN:-}.dmg" + [windows-x86_64]="OBS-Studio-*-Windows-x64${PATTERN:-}.zip" + [windows-arm64]="OBS-Studio-*-Windows-ARM64${PATTERN:-}.zip" + ) + + local -A artifact_patterns=( + [macos-arm64]="obs-studio-macos-arm64-*${PATTERN:--unsigned}.dmg" + [macos-x86_64]="obs-studio-macos-x86_64-*${PATTERN:--unsigned}.dmg" + [windows-x86_64]="obs-studio-windows-x86_64-*${PATTERN:--unsigned}.zip" + [windows-arm64]="obs-studio-windows-arm64-*${PATTERN:--unsigned}.zip" + ) + + local platform_tuple="${PLATFORM,,*}-${ARCHITECTURE,,*}" + + if [[ "${RUNNER_OS}" == 'Windows' ]]; then + DESTINATION="$(cygpath --unix "${DESTINATION}")" + fi + + mkdir -p "${DESTINATION}" + + local download_pattern + case "${GITHUB_EVENT_NAME}" in + release) download-release-asset ;; + workflow_dispatch) + case "${GITHUB_REF_TYPE}" in + tag) download-tag-asset ;; + *) download-artifact ;; + esac + ;; + schedule|push) download-artifact ;; + *) + echo "::error::Unsupported GitHub event name '${GITHUB_EVENT_NAME}'" + return 1 + ;; + esac + + GLOBSORT='-mtime' + local output + + local -a found_files=() + if output="$(compgen -G "${DESTINATION}/${download_pattern}")"; then + local file + while read -r file; do + found_files+=("${file}") + done <<< "${output}" + fi + + if (( ! ${#found_files[@]} )); then + echo "::warning::No downloaded files found with pattern '${download_pattern}'." + else + local file_path + if [[ "${RUNNER_OS}" == 'Windows' ]]; then + file_path="$(cygpath --windows "${found_files[0]}")" + else + file_path="${found_files[0]}" + fi + echo "path=${file_path}" >> "${GITHUB_OUTPUT}" + fi +} + +download-assets diff --git a/.github/actions/download-asset/download-custom-asset.bash b/.github/actions/download-asset/download-custom-asset.bash new file mode 100755 index 00000000000000..3b5cd9a6203915 --- /dev/null +++ b/.github/actions/download-asset/download-custom-asset.bash @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 + +set -o errexit +set -o nounset +set -o pipefail + +: "${CI:?}" +if [[ -n "${RUNNER_DEBUG:-}" ]]; then set -x; fi + +download-custom-assets() { + if [[ "${RUNNER_OS}" == 'Windows' ]]; then + DESTINATION="$(cygpath --unix "${DESTINATION}")" + fi + + case "${GITHUB_EVENT_NAME}" in + workflow_dispatch) + mkdir -p "${DESTINATION}" + + local -A url_patterns=( + [macos-arm64]='OBS-Studio-.+-macOS-Apple.dmg' + [macos-x86_64]='OBS-Studio-.+-macOS-Intel.dmg' + [windows-x86_64]='OBS-Studio-.+-Windows-x64.zip' + [windows-arm64]='OBS-Studio-.+-Windows-ARM64.zip' + ) + + local platform_tuple="${PLATFORM,,*}-${ARCHITECTURE,,*}" + + local url_regex="^https:\/\/.+\/.+\/${url_patterns["${platform_tuple}"]}$" + + if [[ "${CUSTOM_ASSET}" =~ ${url_regex} ]]; then + local file_name + file_name="$(basename "${CUSTOM_ASSET}")" + local file_root="${file_name%%.*}" + local file_extension="${file_name#*.}" + local custom_file_name="${file_root}-custom.${file_extension}" + + curl \ + --location \ + --output "${DESTINATION}/${custom_file_name}" \ + -- "${CUSTOM_ASSET}" + + if [[ ! -r "${DESTINATION}/${file_name}" ]]; then + echo "::warning::Custom asset ${file_name} does not replace an existing release asset." + else + rm -r "${DESTINATION:?}/${file_name}" + fi + + mv "${DESTINATION}/${custom_file_name}" "${DESTINATION}/${file_name}" + else + echo "::error::Unsupported custom asset url '${CUSTOM_ASSET}' provided." + return 1 + fi + + ;; + *) + echo "::error::Custom asset download only available for 'workflow_dispatch' events." + return 1 + ;; + esac +} + +download-custom-assets diff --git a/.github/actions/flatpak-builder-lint/action.yaml b/.github/actions/flatpak-builder-lint/action.yaml deleted file mode 100644 index 481ff6c534f752..00000000000000 --- a/.github/actions/flatpak-builder-lint/action.yaml +++ /dev/null @@ -1,75 +0,0 @@ -name: Run flatpak-builder-lint -description: Runs flatpak-builder-lint with exceptions -inputs: - artifact: - description: Type of artifact to lint (builddir, repo, manifest, appstream) - required: true - path: - description: Path to flatpak-builder manifest or Flatpak build directory - required: true - workingDirectory: - description: Working directory to clone flatpak-builder-lint - required: false - default: ${{ github.workspace }} -runs: - using: composite - steps: - - name: Check artifact type - shell: bash - working-directory: ${{ inputs.workingDirectory }} - run: | - : Check artifact input - if ! [[ "${{ inputs.artifact }}" =~ builddir|repo|manifest|appstream ]]; then - echo "::error::Given artifact type is incorrect" - exit 2 - fi - - - name: Run flatpak-builder-lint - id: result - shell: bash - working-directory: ${{ inputs.workingDirectory }} - run: | - : Run flatpak-builder-lint - - return=0 - result="$(flatpak-builder-lint --exceptions --user-exceptions ${GITHUB_ACTION_PATH}/exceptions.json ${{ inputs.artifact }} ${{ inputs.path }})" || return=$? - - if [[ ${return} != 0 && -z "${result}" ]]; then - echo "::error::Error while running flatpak-builder-lint" - exit 2 - fi - - if [[ "${{ inputs.artifact }}" == "appstream" ]]; then - echo "${result}" - - if [[ ${return} != 0 ]]; then echo "::error::Flatpak appstream info is not valid"; fi - - exit ${return} - fi - - # This jq command selects any available array under the 'warnings' key in the JSON document - # or provides an empty array as a fallback if the key is not present. This array is then - # piped to the 'map' function to apply a transformation to every element in the array, - # converting it to a string prefixed with the output level, the actual element value, and - # finally the suffix string defined in 'template'. - # - # The result of this expression is concatenated with a similar expression doing the same - # but for the 'errors' key and its associated array. - # - # The second jq invocation then selects each element of the array and outputs it directly, - # which will be strings of the formats: - # - # * '::warning::