From fa6283129117bc84622f2216c89c7329c8770abe Mon Sep 17 00:00:00 2001 From: Paul Hutchinson Date: Wed, 21 May 2025 11:50:49 +0000 Subject: [PATCH 1/2] Improve CI/CD workflows (and support merge queues) --- .github/workflows/branch_build.yml | 122 --------------------- .github/workflows/feature_build.yml | 28 +++++ .github/workflows/main_merge.yml | 27 +++++ .github/workflows/pr_build.yml | 95 ---------------- .github/workflows/subworkflow-build.yml | 126 ++++++++++++++++++++++ .github/workflows/subworkflow-deploy.yml | 126 ++++++++++++++++++++++ .github/workflows/subworkflow-release.yml | 86 +++++++++++++++ 7 files changed, 393 insertions(+), 217 deletions(-) delete mode 100644 .github/workflows/branch_build.yml create mode 100644 .github/workflows/feature_build.yml create mode 100644 .github/workflows/main_merge.yml delete mode 100644 .github/workflows/pr_build.yml create mode 100644 .github/workflows/subworkflow-build.yml create mode 100644 .github/workflows/subworkflow-deploy.yml create mode 100644 .github/workflows/subworkflow-release.yml diff --git a/.github/workflows/branch_build.yml b/.github/workflows/branch_build.yml deleted file mode 100644 index da39b60..0000000 --- a/.github/workflows/branch_build.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: Branch Build - -on: - push: - branches: - - main - -jobs: - deskpro_app_test_and_build: - permissions: - contents: write - name: Test / Build - timeout-minutes: 30 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: git fetch --no-tags --depth=1 origin main - - - name: Clone repo - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Prep local dev - run: | - touch ~/.gitconfig - mkdir ~/.ssh - git config --global user.name "$(git log -1 --pretty=format:%an)" - git config --global user.email "$(git log -1 --pretty=format:%ae)" - - - name: Export PR Labels - id: extract_labels - run: echo "labels=$(jq -r '[.[] | .name] | join(",")' <<< '${{ toJson(github.event.pull_request.labels) }}')" >> $GITHUB_OUTPUT - - - name: Lint, Test, Build, and Tag - uses: devcontainers/ci@v0.3 - env: - LABELS: "${{ steps.extract_labels.outputs.labels }}" - with: - env: LABELS - runCmd: | - set -e - - # Lint - pnpm run lint - pnpm tsc --noemit - - # Test - pnpm test:coverage - - # Build - pnpm run build - - # Tag - if [ "$(git log -1 --pretty=format:%ae)" = "noreply@github.com" ]; then - echo "Skipping workflow run because previous commit was made by workflow." - exit 0 - fi - - ## Get current version number - PREV_COMMIT=$(git log -1 --pretty=format:%H -- manifest.json) - VERSION=$(git show $PREV_COMMIT:manifest.json | grep version | head -n 1 | awk -F'"' '{print $4}') - ## Get the commit message - MILESTONE=$(echo "$LABELS" | grep -E 'major-version|minor-version' | head -1) - - echo "Current Version is $VERSION and the milestone is $MILESTONE" - if [[ "$MILESTONE" == "major-version" ]]; then - pnpm run bumpManifestVer major $VERSION - elif [[ "$MILESTONE" == "minor-version" ]]; then - pnpm run bumpManifestVer minor $VERSION - else - pnpm run bumpManifestVer patch $VERSION - fi - - pnpm prettier --write manifest.json - - - name: Update the Manifest in git - run: | - git add manifest.json - git commit -m "Updated Manifest" - git push origin main - - - name: Package app zip - working-directory: dist - run: | - cp ../manifest.json . - zip -rq ../app.zip * - mv ../app.zip . - - name: Read manifest - id: read_manifest - run: | - content=`cat ./manifest.json | tr -d '\n'` - echo "manifest=$content" >> $GITHUB_OUTPUT - - - name: Create safe package filename - id: create_package_filename - run: | - packageFilename=`echo "${{ fromJson(steps.read_manifest.outputs.manifest).name }}" | iconv -t ascii//TRANSLIT | sed -r s/[~\^]+//g | sed -r s/[^a-zA-Z0-9]+/-/g | sed -r s/^-+\|-+$//g | tr A-Z a-z` - echo "packageFilename=$packageFilename" >> $GITHUB_OUTPUT - - - name: Rename package - id: rename_app_package - run: | - (cd ./dist && mv app.zip ${{ steps.create_package_filename.outputs.packageFilename }}.zip) - - - name: Create release - id: create_release - uses: softprops/action-gh-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ fromJson(steps.read_manifest.outputs.manifest).version }} - draft: false - prerelease: false - files: | - ./dist/${{ steps.create_package_filename.outputs.packageFilename }}.zip - ./dist/manifest.json - - release: - uses: DeskproApps/app-template-vite/.github/workflows/subworkflow-release.yml@main - secrets: inherit - needs: [deskpro_app_test_and_build] diff --git a/.github/workflows/feature_build.yml b/.github/workflows/feature_build.yml new file mode 100644 index 0000000..f07ad07 --- /dev/null +++ b/.github/workflows/feature_build.yml @@ -0,0 +1,28 @@ +name: Feature Build + +on: + pull_request: + merge_group: # support for merge queues/groups + workflow_dispatch: + +# Allow this job to be canceled when new commits are pushed +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + uses: ./.github/workflows/subworkflow-build.yml + secrets: inherit + permissions: + contents: write + pull-requests: read + + deploy: + uses: ./.github/workflows/subworkflow-deploy.yml + secrets: inherit + if: github.actor != 'dependabot[bot]' && github.event_name == 'pull_request' + needs: [build] + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/main_merge.yml b/.github/workflows/main_merge.yml new file mode 100644 index 0000000..318558f --- /dev/null +++ b/.github/workflows/main_merge.yml @@ -0,0 +1,27 @@ +name: Main Build + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-build + cancel-in-progress: false + +jobs: + build_and_tag: + uses: ./.github/workflows/subworkflow-build.yml + secrets: inherit + with: + push-tag: true + permissions: + contents: write + pull-requests: read + + release: + uses: ./.github/workflows/subworkflow-release.yml + secrets: inherit + needs: [build_and_tag] + permissions: + contents: write diff --git a/.github/workflows/pr_build.yml b/.github/workflows/pr_build.yml deleted file mode 100644 index 69877b2..0000000 --- a/.github/workflows/pr_build.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: PR Build - -on: - pull_request: - branches: - - main - -jobs: - deskpro_app_test_and_build: - name: Test / Build - timeout-minutes: 30 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: git fetch --no-tags --depth=1 origin main - - - name: Clone repo - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Prep local dev - run: | - touch ~/.gitconfig - mkdir ~/.ssh - git config --global user.name "$(git log -1 --pretty=format:%an)" - git config --global user.email "$(git log -1 --pretty=format:%ae)" - - - name: Export PR Labels - id: extract_labels - run: echo "labels=$(jq -r '[.[] | .name] | join(",")' <<< '${{ toJson(github.event.pull_request.labels) }}')" >> $GITHUB_OUTPUT - - - name: Lint, Test, Build, and Tag - uses: devcontainers/ci@v0.3 - env: - LABELS: "${{ steps.extract_labels.outputs.labels }}" - with: - env: LABELS - runCmd: | - set -e - - # Lint - pnpm run lint - pnpm tsc --noemit - - # Test - pnpm test:coverage - - # Build - pnpm run build - - # Tag - if [ "$(git log -1 --pretty=format:%ae)" = "noreply@github.com" ]; then - echo "Skipping workflow run because previous commit was made by workflow." - exit 0 - fi - - ## Get current version number - PREV_COMMIT=$(git log -1 --pretty=format:%H -- manifest.json) - VERSION=$(git show $PREV_COMMIT:manifest.json | grep version | head -n 1 | awk -F'"' '{print $4}') - - ## Get the commit message - MILESTONE=$(echo "$LABELS" | grep -E 'major-version|minor-version' | head -1) - - echo "Current Version is $VERSION and the milestone is $MILESTONE" - if [[ "$MILESTONE" == "major-version" ]]; then - pnpm run bumpManifestVer major $VERSION - elif [[ "$MILESTONE" == "minor-version" ]]; then - pnpm run bumpManifestVer minor $VERSION - else - pnpm run bumpManifestVer patch $VERSION - fi - - pnpm prettier --write manifest.json - - - name: Package app zip - working-directory: dist - run: | - cp ../manifest.json . - zip -rq ../app.zip * - mv ../app.zip . - - name: Upload package - uses: actions/upload-artifact@v4 - with: - name: app-package - path: | - dist/app.zip - dist/manifest.json - retention-days: 7 - - deploy: - uses: DeskproApps/app-template-vite/.github/workflows/subworkflow-deploy.yml@main - secrets: inherit - if: github.actor != 'dependabot[bot]' - needs: [deskpro_app_test_and_build] diff --git a/.github/workflows/subworkflow-build.yml b/.github/workflows/subworkflow-build.yml new file mode 100644 index 0000000..e8311e7 --- /dev/null +++ b/.github/workflows/subworkflow-build.yml @@ -0,0 +1,126 @@ +name: "Build" + +on: + workflow_call: + inputs: + push-tag: + type: boolean + description: "Should the version tag be pushed to the repo" + default: false + required: false + run-lint: + type: boolean + description: "Should the lint checks be run" + default: true + required: false + run-tests: + type: boolean + description: "Should the tests be run" + default: true + required: false + +jobs: + build: + name: Lint / Test / Build / Tag + timeout-minutes: 30 + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + steps: + - name: Clone repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Prep local dev + run: | + touch ~/.gitconfig + mkdir ~/.ssh + git config --global user.name "$(git log -1 --pretty=format:%an)" + git config --global user.email "$(git log -1 --pretty=format:%ae)" + + - name: Export PR Labels + id: extract_labels + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ -n "${{ github.event.pull_request.number }}" ]; then + # Regular PR case + echo "PR number found: ${{ github.event.pull_request.number }}" + labels=$(jq -r '[.[] | .name] | join(",")' <<< '${{ toJson(github.event.pull_request.labels) }}') + else + # Merge queue case - find PR by head ref + echo "No PR number found, checking for merge group" + PR_NUM=$(git log -1 --pretty=%B | grep -oP '#\K\d+') + echo "PR number found: $PR_NUM" + if [ -n "$PR_NUM" ]; then + labels=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \ + "https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUM/labels" | \ + jq -r '[.[] | .name] | join(",")') + fi + fi + echo "labels=${labels:-}" >> $GITHUB_OUTPUT + + - name: Export Version Tag + id: version_tag + run: | + tag=$(git tag --merged HEAD --sort=-version:refname -l "[0-9]*.[0-9]*.[0-9]*" -l "v[0-9]*.[0-9]*.[0-9]*" | head -n 1); + echo version=$([ -z "$tag" ] && echo "0.0.0" || echo "${tag#v}") >> $GITHUB_OUTPUT; + + - name: Lint, Test, Build, and Tag + uses: devcontainers/ci@8bf61b26e9c3a98f69cb6ce2f88d24ff59b785c6 #v0.3 + env: + LABELS: "${{ steps.extract_labels.outputs.labels }}" + VERSION: "${{ steps.version_tag.outputs.version }}" + PUSH_TAG: "${{ inputs.push-tag }}" + RUN_LINT: "${{ inputs.run-lint }}" + RUN_TESTS: "${{ inputs.run-tests }}" + with: + env: | + LABELS + VERSION + PUSH_TAG + RUN_LINT + RUN_TESTS + runCmd: | + set -e + + # Lint + if [ "$RUN_LINT" = true ]; then + pnpm run lint + pnpm tsc --noemit + fi + + # Test + if [ "$RUN_TESTS" = true ]; then + pnpm test:coverage + fi + + # Build + pnpm run build + + # Tag + MILESTONE=$(echo "$LABELS" | grep -E 'major-version|minor-version' | head -1) + VERSION_NEW=$(pnpm run bumpManifestVer "$MILESTONE" "$VERSION" | tail -n 1) + pnpm prettier --write manifest.json + git tag -a $VERSION_NEW -m "Version $VERSION_NEW" + if [ "$PUSH_TAG" = "true" ]; then + git push origin $VERSION_NEW + fi + + - name: Package app zip + working-directory: dist + run: | + cp ../manifest.json . + zip -rq ../app.zip * + + - name: Upload package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: app-package + path: | + app.zip + manifest.json + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/subworkflow-deploy.yml b/.github/workflows/subworkflow-deploy.yml new file mode 100644 index 0000000..99cb2a1 --- /dev/null +++ b/.github/workflows/subworkflow-deploy.yml @@ -0,0 +1,126 @@ +name: "Deploy PR demo" + +on: + workflow_call: + inputs: + deskpro-docker-repository: + type: string + description: "Deskpro docker repository" + required: false + default: "registry.deskprodemo.com/deskpro/deskpro-product-dev" + + mysql-docker-repository: + type: string + description: "MySQL docker repository" + required: false + default: "registry.deskprodemo.com/deskpro/deskpro-mysql-dev" + + artifact-name: + type: string + description: "The artifact name to download that contains the package build output" + default: "app-package" + required: false + + secrets: + DESKPRO_SERVICE_TOKEN: { required: true } + DEMO_SERVER_SSH_KEY: { required: true } + DEMO_SERVER_SSH_USER: { required: true } + DEMO_SERVER_SSH_HOST: { required: true } + DOCKER_REPO_USERNAME: { required: true } + DOCKER_REPO_TOKEN: { required: true } + DESKPRO_LICENSE_KEY: { required: true } + DESKPRO_DEMO_FQDN: { required: true } + APP_SETTINGS: { required: true } + +jobs: + deploy: + name: Deploy / Deskpro Demo + timeout-minutes: 10 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - run: | + mkdir -p compose + + curl -fsSL \ + -H "Accept: application/vnd.github.VERSION.raw" \ + -H "Authorization: token ${{ secrets.DESKPRO_SERVICE_TOKEN }}" \ + -o compose/docker-compose.yml \ + https://api.github.com/repos/deskpro/deskpro-product/contents/docker/compose/deskpro-demo/docker-compose.yml + + - name: Create Docker SSH context + uses: deskpro/gh-actions/create-ssh-docker-context@master + with: + context-name: "demo" + private-key: ${{ secrets.DEMO_SERVER_SSH_KEY }} + user: ${{ secrets.DEMO_SERVER_SSH_USER }} + host: ${{ secrets.DEMO_SERVER_SSH_HOST }} + + - name: Switch Docker context + run: | + docker context use demo + + - name: Deploy demo + id: deployment + uses: deskpro/gh-actions/deploy-deskpro-demo@master + with: + compose-project-path: compose + + deskpro-docker-repository: ${{ inputs.deskpro-docker-repository }} + mysql-docker-repository: ${{ inputs.mysql-docker-repository }} + + registry-url: registry.deskprodemo.com + registry-username: ${{ secrets.DOCKER_REPO_USERNAME }} + registry-password: ${{ secrets.DOCKER_REPO_TOKEN }} + + license-key: ${{ secrets.DESKPRO_LICENSE_KEY }} + vhost-domain: ${{ secrets.DESKPRO_DEMO_FQDN }} + + url-resource: "/horizon-ui/app" + + - name: Lookup container ID + id: container + run: | + service_name="${{ steps.deployment.outputs.swarm-stack-name }}_deskpro" + service_id="$(docker service ps -q "${service_name}" | head -1)" + + if [ -z "${service_id}" ]; then + echo "::error title=Deskpro Swarm service ${service_name} not found::Deskpro Swarm service ${service_name} not found" + exit 1 + fi + + container_id="$(docker inspect --format '{{.Status.ContainerStatus.ContainerID}}' "${service_id}")" + echo "id=${container_id}" >> $GITHUB_OUTPUT + + - name: Download package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs.artifact-name }} + path: app-package + + - name: Discover app name + id: app + working-directory: app-package + run: | + app_name=$(jq -r '.name' manifest.json) + echo "name=${app_name}" >> $GITHUB_OUTPUT + + - name: Copy package to container + working-directory: app-package + run: | + docker cp app.zip "${{ steps.container.outputs.id }}:/srv/deskpro/tools/fixtures/resources/custom_app_packages/app.zip" + + - name: Add package + run: | + docker exec "${{ steps.container.outputs.id }}" php /srv/deskpro/tools/fixtures/artisan apps:insert \ + --package=app.zip + + - name: Install package + run: | + docker exec "${{ steps.container.outputs.id }}" php /srv/deskpro/tools/fixtures/artisan apps:install \ + --name="${{ steps.app.outputs.name }}" \ + --settings=${{ toJSON(secrets.APP_SETTINGS) }} \ + --permission-person-ids=1 \ + --uninstall-instances-before-install diff --git a/.github/workflows/subworkflow-release.yml b/.github/workflows/subworkflow-release.yml new file mode 100644 index 0000000..426a46b --- /dev/null +++ b/.github/workflows/subworkflow-release.yml @@ -0,0 +1,86 @@ +name: "Release to App Marketplace" + +on: + workflow_call: + inputs: + server_url: + type: string + description: "The URL of the GitHub server URL." + default: ${{ github.server_url }} + required: false + repository: + type: string + description: "The owner and repository name to release." + default: ${{ github.repository }} + required: false + + secrets: + APP_REGISTRY_KEY: { required: true } + +jobs: + release: + name: Release App + timeout-minutes: 10 + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: app-package + path: app-package + + - name: Create safe package + id: package + working-directory: app-package + run: | + packageFilename=`jq -r .name manifest.json | iconv -t ascii//TRANSLIT | sed -r s/[~\^]+//g | sed -r s/[^a-zA-Z0-9]+/-/g | sed -r s/^-+\|-+$//g | tr A-Z a-z` + mv app.zip $packageFilename.zip + echo "version=`jq -r .version manifest.json`" >> $GITHUB_OUTPUT + echo "packageFilename=$packageFilename.zip" >> $GITHUB_OUTPUT + + - name: Create release + id: create_release + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.package.outputs.version }} + draft: false + prerelease: false + generate_release_notes: true + files: | + ./app-package/${{ steps.package.outputs.packageFilename }} + ./app-package/manifest.json + + - name: Check if can release to Deskpro Infrastructure + id: check_deskpro_release + run: | + if [ "${{ github.repository_visibility }}" != "public" ]; then + echo "Repository is not public. Skipping deployment." + echo "can_deploy=false" >> $GITHUB_OUTPUT + elif [ -z "${{ secrets.APP_REGISTRY_KEY }}" ]; then + echo "APP_REGISTRY_KEY is not set. Skipping deployment." + echo "can_deploy=false" >> $GITHUB_OUTPUT + else + echo "can_deploy=true" >> $GITHUB_OUTPUT + fi + + - name: Register release with apps registry + if: steps.check_deskpro_release.outputs.can_deploy == 'true' + run: | + curl --fail -X POST \ + -H "Content-Type: application/json" \ + -H "x-api-key: ${{ secrets.APP_REGISTRY_KEY }}" \ + -d '{"repositoryUrl": "'${{ inputs.server_url }}/${{ inputs.repository }}'", "type": "github"}' \ + https://apps.deskpro-service.com/register + + - name: Trigger release + if: steps.check_deskpro_release.outputs.can_deploy == 'true' + run: | + curl --fail -X POST \ + -H "Content-Type: application/json" \ + -H "x-api-key: ${{ secrets.APP_REGISTRY_KEY }}" \ + -d '{"repositoryUrl": "'${{ inputs.server_url }}/${{ inputs.repository }}'"}' \ + https://apps.deskpro-service.com/release \ No newline at end of file From 04c2c76196bf87eec1b46c025dadbc020a8c6dfd Mon Sep 17 00:00:00 2001 From: Paul Hutchinson Date: Wed, 21 May 2025 12:12:13 +0000 Subject: [PATCH 2/2] Improve CI/CD workflows (and support merge queues) --- bin/bumpManifestVer.js | 49 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/bin/bumpManifestVer.js b/bin/bumpManifestVer.js index d95f98d..4c9d03b 100644 --- a/bin/bumpManifestVer.js +++ b/bin/bumpManifestVer.js @@ -1,36 +1,37 @@ const fs = require("fs"); -const bumpSemanticVersion = (versionString, bumpType = "patch") => { - let [major, minor, patch] = versionString.split("."); - - switch (bumpType) { - case "major": - major = parseInt(major) + 1; - minor = 0; - patch = 0; - break; - - case "minor": - minor = parseInt(minor) + 1; - patch = 0; - break; - - case "patch": - patch = parseInt(patch) + 1; - break; +/** + * @param {string} versionStringRaw + * @param {string} labels + */ +function bumpSemanticVersion(versionStringRaw, labels) { + const versionString = (versionStringRaw ?? '0.0.0').trim(); + if (!versionString.match(/^\d+\.\d+\.\d+$/)) { + throw new Error("Invalid version string: " + versionStringRaw); + } - default: - break; + let [major, minor, patch] = versionString.trim().split("."); + + if (labels?.includes("major")) { + major = parseInt(major) + 1; + minor = 0; + patch = 0; + } else if (labels?.includes("minor")) { + minor = parseInt(minor) + 1; + patch = 0; + } else { + patch = parseInt(patch) + 1; } return `${major}.${minor}.${patch}`; }; const packageJson = JSON.parse(fs.readFileSync("./manifest.json", "utf8")); -//1 + packageJson.version = bumpSemanticVersion( - process.argv[3] ? process.argv[3] : packageJson.version, - process.argv[2] + process.argv[3], + process.argv[2], ); -fs.writeFileSync("./manifest.json", JSON.stringify(packageJson)); +fs.writeFileSync("./manifest.json", JSON.stringify(packageJson, null, 2)); +console.log(packageJson.version);