From ed89b44fbf447139b9a48b760fbfde6f28c03538 Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Thu, 9 Apr 2026 11:56:16 +0200 Subject: [PATCH 1/8] Create deployment in other repos In order to show integration status, create deployments (in each component!) when they are integrated. PR builds create transient deployments, while a new post-merge step Issue: ZENKO-5132 --- .../create-component-deployments/action.yaml | 91 ++++++++ .../create-deployments.js | 102 +++++++++ .../parse-deps.js | 48 ++++ .github/workflows/end2end.yaml | 80 +++++++ .github/workflows/postmerge.yaml | 27 +++ .gitignore | 3 + .../create-component-deployments.spec.ts | 173 +++++++++++++++ tests/workflows/create-deployments.spec.ts | 207 ++++++++++++++++++ tests/workflows/jest.config.ts | 1 + tests/workflows/package.json | 2 + tests/workflows/parse-deps.spec.ts | 69 ++++++ .../test-create-component-deployments.yaml | 24 ++ tests/workflows/test-deps.yaml | 15 ++ tests/workflows/yarn.lock | 17 ++ 14 files changed, 859 insertions(+) create mode 100644 .github/actions/create-component-deployments/action.yaml create mode 100644 .github/actions/create-component-deployments/create-deployments.js create mode 100644 .github/actions/create-component-deployments/parse-deps.js create mode 100644 .github/workflows/postmerge.yaml create mode 100644 tests/workflows/create-component-deployments.spec.ts create mode 100644 tests/workflows/create-deployments.spec.ts create mode 100644 tests/workflows/parse-deps.spec.ts create mode 100644 tests/workflows/test-create-component-deployments.yaml create mode 100644 tests/workflows/test-deps.yaml diff --git a/.github/actions/create-component-deployments/action.yaml b/.github/actions/create-component-deployments/action.yaml new file mode 100644 index 0000000000..233e9b943b --- /dev/null +++ b/.github/actions/create-component-deployments/action.yaml @@ -0,0 +1,91 @@ +name: Manage Component Deployments +description: > + Create or update GitHub Deployments on scality component repos + referenced in deps.yaml, providing integration visibility. + Generates a minimally-scoped token (deployments:write only, + limited to the exact repos found in deps.yaml). + +inputs: + app-id: + description: GitHub App ID for token generation + required: true + app-private-key: + description: GitHub App private key for token generation + required: true + deps-file: + description: Path to deps.yaml + required: false + default: solution/deps.yaml + environment: + description: Deployment environment name (e.g. zenko/development/2.11) + required: true + status: + description: Deployment status (in_progress, success, failure) + required: true + transient: + description: Whether deployments are transient (auto-inactivated on newer success) + required: false + default: 'false' + log-url: + description: URL to link from the deployment status + required: true + description: + description: Human-readable deployment status description + required: false + default: '' + github-token: + description: GitHub token for github-script (only needed for act.js tests) + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Parse component repos from deps.yaml + id: parse + uses: actions/github-script@v7 + with: + github-token: ${{ inputs.github-token }} + script: | + const { parseDeps } = require('${{ github.action_path }}/parse-deps.js'); + const selfRepo = process.env.GITHUB_REPOSITORY || 'scality/zenko'; + const { components, repos } = parseDeps('${{ inputs.deps-file }}', selfRepo); + + if (components.length === 0) { + core.info('No component repos found in deps.yaml'); + core.setOutput('components', ''); + return; + } + + core.setOutput('components', JSON.stringify(components)); + core.setOutput('repos', repos.join('\n')); + + - name: Generate scoped deployments token + if: steps.parse.outputs.components != '' + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.app-private-key }} + owner: ${{ github.repository_owner }} + repositories: ${{ steps.parse.outputs.repos }} + permission-deployments: write + + - name: Create or update deployments + if: steps.parse.outputs.components != '' + uses: actions/github-script@v7 + env: + COMPONENTS: ${{ steps.parse.outputs.components }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { createDeployments } = require('${{ github.action_path }}/create-deployments.js'); + await createDeployments({ + github, core, + components: JSON.parse(process.env.COMPONENTS), + environment: `${{ inputs.environment }}`, + status: `${{ inputs.status }}`, + transient: ${{ inputs.transient }}, + logUrl: `${{ inputs.log-url }}`, + description: `${{ inputs.description }}`, + }); diff --git a/.github/actions/create-component-deployments/create-deployments.js b/.github/actions/create-component-deployments/create-deployments.js new file mode 100644 index 0000000000..7486e6cb2e --- /dev/null +++ b/.github/actions/create-component-deployments/create-deployments.js @@ -0,0 +1,102 @@ +// @ts-check + +/** + * @typedef {import('@octokit/rest').Octokit} Octokit + * @typedef {{ info: (msg: string) => void, warning: (msg: string) => void, startGroup: (name: string) => void, endGroup: () => void }} Core + */ + +/** + * Find an existing deployment or create a new one. + * + * For status updates (success/failure), looks up an existing deployment first. + * For in_progress, always creates a fresh one. + * + * @param {Octokit} github + * @param {object} params + * @param {string} params.owner + * @param {string} params.repo + * @param {string} params.ref + * @param {string} params.environment + * @param {string} params.description + * @param {boolean} params.transient + * @param {boolean} params.createOnly - Skip lookup, always create + * @returns {Promise} deployment id + */ +async function findOrCreateDeployment(github, { owner, repo, ref, environment, description, transient, createOnly }) { + if (!createOnly) { + const { data: existing } = await github.rest.repos.listDeployments({ + owner, repo, environment, ref, per_page: 1, + }); + if (existing.length > 0) { + return existing[0].id; + } + } + + const { data } = await github.rest.repos.createDeployment({ + owner, repo, ref, + environment, + description, + auto_merge: false, + required_contexts: [], + transient_environment: transient, + production_environment: false, + }); + + return data.id; +} + +/** + * Create or update GitHub Deployments on component repos. + * + * @param {object} params + * @param {Octokit} params.github - Octokit instance + * @param {Core} params.core - GitHub Actions core + * @param {Array<{repo: string, ref: string}>} params.components - Parsed component list + * @param {string} params.environment - Deployment environment name + * @param {string} params.status - Deployment status (in_progress, success, failure) + * @param {boolean} params.transient - Whether deployments are transient + * @param {string} params.logUrl - URL to link from the deployment status + * @param {string} params.description - Human-readable description + */ +async function createDeployments({ github, core, components, environment, status, transient, logUrl, description }) { + let errors = 0; + + for (const { repo, ref } of components) { + const [owner, repoName] = repo.split('/'); + core.startGroup(`${repo} @ ${ref}`); + + try { + const deploymentId = await findOrCreateDeployment(github, { + owner, repo: repoName, ref, + environment, description, transient, + createOnly: status === 'in_progress', + }); + core.info(`Deployment ${deploymentId}`); + + await github.rest.repos.createDeploymentStatus({ + owner, repo: repoName, + deployment_id: deploymentId, + state: status, + log_url: logUrl, + description, + }); + core.info(`Status: ${status}`); + } catch (/** @type {any} */ err) { + core.warning(`Failed on ${repo}: ${err.message}`); + errors++; + } + + core.endGroup(); + } + + if (errors > 0) { + core.warning(`${errors} deployment(s) failed (non-fatal)`); + } + + return errors; +} + +module.exports = { + findOrCreateDeployment, + createDeployments, +}; diff --git a/.github/actions/create-component-deployments/parse-deps.js b/.github/actions/create-component-deployments/parse-deps.js new file mode 100644 index 0000000000..d0cdeb4ebf --- /dev/null +++ b/.github/actions/create-component-deployments/parse-deps.js @@ -0,0 +1,48 @@ +// @ts-check +const fs = require('fs'); +const yaml = require('js-yaml'); + +/** + * Parse deps.yaml and extract unique {repo, ref} pairs for ghcr.io/scality/* images. + * + * @param {string} depsFile - Path to deps.yaml + * @param {string} selfRepo - The current repo (org/name) to exclude from results + * @returns {{ components: Array<{repo: string, ref: string}>, repos: string[] }} + */ +function parseDeps(depsFile, selfRepo) { + const deps = yaml.load(fs.readFileSync(depsFile, 'utf8')); + const seen = new Set(); + const components = []; + const normalizedSelfRepo = (selfRepo || '').toLowerCase(); + + for (const [, entry] of Object.entries(deps)) { + const registry = (entry.sourceRegistry || '') + '/'; + if (!registry.startsWith('ghcr.io/scality/') || !entry.image || !entry.tag) { + continue; + } + + // ghcr.io/scality/zenko/kafka -> scality/zenko + const fullPath = registry.replace(/^ghcr\.io\//, '') + entry.image; + const repo = fullPath.split('/').slice(0, 2).join('/'); + + // GitHub repository names are case-insensitive, normalize to avoid false negatives. + if (repo.toLowerCase() === normalizedSelfRepo) { + continue; + } + + const key = `${repo} ${entry.tag}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + components.push({ repo, ref: entry.tag }); + } + + // Unique repo short names (without org/) for token scoping + const repos = [...new Set(components.map(c => c.repo.split('/')[1]))]; + + return { components, repos }; +} + +module.exports = { parseDeps }; diff --git a/.github/workflows/end2end.yaml b/.github/workflows/end2end.yaml index bdd2ea2ded..ec6d1f714f 100644 --- a/.github/workflows/end2end.yaml +++ b/.github/workflows/end2end.yaml @@ -677,6 +677,86 @@ jobs: junit-paths: ${{ github.workspace }}/tests/ctst/reports/*.xml if: always() + create-deployments: + runs-on: ubuntu-24.04 + outputs: + environment: ${{ steps.env.outputs.environment }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Determine target environment + id: env + env: + GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.ref_name }} + run: | + base_ref=$(gh pr list --head "$REF_NAME" --state open \ + --json baseRefName --jq '.[0].baseRefName // empty') + if [[ -n "$base_ref" && "$base_ref" == development/* ]]; then + echo "environment=zenko/$base_ref" >> "$GITHUB_OUTPUT" + echo "Target environment: zenko/$base_ref" + else + echo "No open PR targeting a development branch found, skipping deployments" + fi + - name: Create transient deployments + if: steps.env.outputs.environment != '' + uses: ./.github/actions/create-component-deployments + with: + app-id: ${{ vars.ACTIONS_APP_ID }} + app-private-key: ${{ secrets.ACTIONS_APP_PRIVATE_KEY }} + environment: ${{ steps.env.outputs.environment }} + status: in_progress + transient: 'true' + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + description: Zenko CI running + + update-deployments: + runs-on: ubuntu-24.04 + needs: + - create-deployments + - build-iso + - build-kafka + - end2end-2-shards-http + - end2end-sharded + - end2end-pra + - ctst-end2end-sharded + if: always() && needs.create-deployments.outputs.environment != '' + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Determine CI result + id: result + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + status=$(jq -r ' + del(.["create-deployments"]) | + to_entries | + if any(select(.key | test("build")) | .value.result | IN("failure","cancelled")) then + "error" + elif any(.value.result | IN("failure","cancelled")) then + "failure" + else + "success" + end + ' <<<"$NEEDS_JSON") + echo "status=$status" >> "$GITHUB_OUTPUT" + echo "CI result: $status" + - name: Update deployment statuses + uses: ./.github/actions/create-component-deployments + with: + app-id: ${{ vars.ACTIONS_APP_ID }} + app-private-key: ${{ secrets.ACTIONS_APP_PRIVATE_KEY }} + environment: ${{ needs.create-deployments.outputs.environment }} + status: ${{ steps.result.outputs.status }} + transient: 'true' + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + description: >- + Zenko CI ${{ + steps.result.outputs.status == 'success' && 'passed' || + steps.result.outputs.status == 'error' && 'build failed' || + 'tests failed' }} + write-final-status: runs-on: ubuntu-24.04 needs: diff --git a/.github/workflows/postmerge.yaml b/.github/workflows/postmerge.yaml new file mode 100644 index 0000000000..7246074309 --- /dev/null +++ b/.github/workflows/postmerge.yaml @@ -0,0 +1,27 @@ +name: Post Merge + +on: + push: + branches: + - development/* + +permissions: + contents: read + deployments: write + +jobs: + create-deployments: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create permanent deployments + uses: ./.github/actions/create-component-deployments + with: + app-id: ${{ vars.ACTIONS_APP_ID }} + app-private-key: ${{ secrets.ACTIONS_APP_PRIVATE_KEY }} + environment: "zenko/${{ github.ref_name }}" + status: success + log-url: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }} + description: "Integrated in zenko ${{ github.ref_name }}" diff --git a/.gitignore b/.gitignore index ede03d89cf..1eda6513e0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ tests/artifacts **/_build docs/_templates/page.html_orig *.pyc + +tests/workflows/repo +tests/workflows/act-*.log diff --git a/tests/workflows/create-component-deployments.spec.ts b/tests/workflows/create-component-deployments.spec.ts new file mode 100644 index 0000000000..8bd667f7c4 --- /dev/null +++ b/tests/workflows/create-component-deployments.spec.ts @@ -0,0 +1,173 @@ +import { Act } from "@kie/act-js"; +import { MockGithub, Moctokit } from "@kie/mock-github"; +import nock from "nock"; +import path from "path"; +import { exec as execCb } from "node:child_process"; +import { promisify } from "node:util"; + +const exec = promisify(execCb); + +// Test-only RSA key (not used anywhere else, generated for act.js mock) +const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDSSu4ghRVAKyjX +c25FKdE+sARk3Jai8k8DCjJU/DMNskgNvGh7JPLDww98Ts9E3ddMNt06oBt5p8qc +YfaInUR0poPcJG6JbPWVEefNC00dTiHlXEGU8Ih8Dc2Ezf/zb45my9DmmGXeVScf +LNYtSSqxfqdjFIOvr8XJ8kuB7L3jyFTBN9xjfdTnmsJ0ilSatV50o6RdVjiZvf2r +z8uhlIw/b0xw8ZZ5rRXNr1VgDW7kcK952OYIDo9qvGMxOASjx9cpUJBkE/nrWF8I +HAGACAqUZJaF4p/CQFjd/7cmUpA+Shh31UdD/rSXzC1bTEB5vaJN8LyVEBYw+n19 +iv6QO/K1AgMBAAECggEAaRNksd4dlK8cHK+CQU/YTGzx/R3VrPzLKxcsuBc+QVE8 +PJTQVfvLy7JLKg9M9LmuStg9KX53zA1hqUsvvupqGqlbSKPxkXxep4pHW0aS1RpF +yI+U+2FGqUnST9II2q/6pPWhX591gybkQekK6ZzeFstUwyaseBwphbMqNHTBGy+F +9A52Zg42QBQbQoIsOiqpJTKxhDpdEx+AZqrG1EawQkygVeNyRnOaJgCgVTIxqk+m +lENCchKeZmo6aul/4LwH9GPDF+1ftMIlAwFXwQgp/IuzFmNf564NSIEZbWgYC8aN +gl3ZU5K6mrwbKpGg2HXPDb6AA0U4WN1Bmw8wLyWhjwKBgQDsgNSi4+kfjLJM9oHQ +PkNTfZ7rmrSdPf8gAodGsrABKz9IhfoCQJ0jNtegaGq3JB+YVJUUWtw4cnyBGOcE +f1F9JIRMcq885p9zwX0jDLLe1vR8305tmYJFweqAViwWQ2riy2IqupsWEhgKev1S +8QgSsaDcP1wksDWokykui85TzwKBgQDjoPOc1zs1teMG5zpSQVm3QT2C3ryRSBRo ++1jyszl8Rrm7x9IcmiyBL5XQOpHoNHH24wUDte+t08V/34mhEwaa3tl1MUmJLsBG ++LkTVXdRcFnHoqCbqFqYeV8eXQgY+Narq245VGGa1CfkhHvqQvuKVlIDLgPPrutg +czA0MpW+OwKBgQDU4ImFLTwzR8Nd/yyNst2LEzGuxIv6VUmFGIGHI2PFSZYmw2Fs +EZjfj4e7PQGBY6SEyu19auN6c6KZ2T5oD+nbiLkEzt3pJXU1Dl6C4/VFG5rpo17G +zDw0af2YEviP+ZMGHSd5aooZ7aNyG45Vz9sCaJxwYx+fbnR+DigtW24WhQKBgQCf +sPXXTWO7jYvk9ukCddhT6NAXdN2Darbu446GTdgBaLi6lTfBWyPnyZNnjv93kPt2 +wdNtxACOyWfgCtnKB8f1dGvIfLhjJko8QBfPCYF4v8IsfNoB+bz9BQEHEyswIbqw +msbsL1d+QGJwPcWVFkLTzTUiB/EijUuR0Z26sNY+qwKBgQDWDEEBjZ+63kttpp4c +EAyXAIwDT+KhVppmXvIAjVkqP6+I8yqUSCFjXMTT1Bubovqk6wAnpYdA559LgRjc +gfkN+TRRaIeVB9jxzFHszenX6CVswwBtwSj331N/87GnI7fF7/ZDmMKRSiRjIQyL +6c4hJmUD3bnLspBgcbLD33c7Dw== +-----END PRIVATE KEY-----`; + +let github: MockGithub; +let moctokit: Moctokit; +let act: Act; + +async function getCommitHash(repo: string = "zenko") { + const { stdout } = await exec("git -C " + github.repo.getPath(repo) + " rev-parse HEAD"); + return stdout.trim(); +} + +// Common deployment parameters expected for all components +const commonDeploymentParams = { + environment: "zenko/development/2.11", + description: "Zenko CI running", + auto_merge: false, + required_contexts: [], + transient_environment: true, + production_environment: false, +}; + +beforeEach(async () => { + github = new MockGithub({ + repo: { + zenko: { + currentBranch: "improvement/ZENKO-5210", + files: [ + { + src: path.resolve(__dirname, "../..", ".github"), + dest: ".github", + }, + { + src: path.resolve(__dirname, "test-deps.yaml"), + dest: "solution/deps.yaml", + }, + { + src: path.resolve(__dirname, "test-create-component-deployments.yaml"), + dest: ".github/workflows/test-create-component-deployments.yaml", + }, + ], + }, + }, + }); + await github.setup(); + + moctokit = new Moctokit("http://api.github.com"); + + act = new Act(github.repo.getPath("zenko")); + act.setWorkflowFile(".github/workflows/test-create-component-deployments.yaml"); + + act.setEnv("GITHUB_JOB", "test-deployments"); + act.setEnv("GITHUB_SHA", await getCommitHash()); + act.setEnv("GITHUB_REPOSITORY", "scality/zenko"); + act.setEnv("GITHUB_API_URL", "http://api.github.com"); + act.setEnv("GITHUB_RUN_ID", "1"); + act.setEnv("GITHUB_TOKEN", "fake-token"); + + act.setSecret("APP_PRIVATE_KEY", TEST_PRIVATE_KEY); + + act.setPlatforms("ubuntu-24.04", "ghcr.io/catthehacker/ubuntu:act-24.04"); +}); + +afterEach(async () => { + nock.cleanAll(); + await github.teardown(); +}); + +describe("create-component-deployments action", () => { + it("should parse deps.yaml and create deployments on component repos", async () => { + // Post-step: token revocation (DELETE /installation/token, not in Moctokit) + nock("http://api.github.com").delete("/installation/token").reply(204).persist(); + + const result = await act.runEvent("workflow_dispatch", { + logFile: process.env.ACT_LOG ? path.join(__dirname, "act-deployments.log") : undefined, + mockApi: [ + // Mock create-github-app-token: get installation then create token + moctokit.rest.apps.getRepoInstallation().reply({ + status: 200, + data: { id: 1, app_id: 12345, app_slug: "test-app" }, + repeat: 5, + }), + moctokit.rest.apps.createInstallationAccessToken().reply({ + status: 201, + data: { token: "ghs_fake_token" }, + repeat: 5, + }), + + // test-deps.yaml has 2 scality components (sorbet + backbeat) + // kafka is scality/zenko (filtered as self-repo), redis has no scality registry + + // Sorbet: createDeployment + createDeploymentStatus + moctokit.rest.repos.createDeployment({ + owner: "scality", + repo: "sorbet", + ref: "v1.2.2", + ...commonDeploymentParams, + }).reply({ status: 201, data: { id: 101 } }), + + moctokit.rest.repos.createDeploymentStatus({ + owner: "scality", + repo: "sorbet", + deployment_id: 101, + state: "in_progress", + log_url: "https://github.com/scality/zenko/actions/runs/1", + description: "Zenko CI running", + }).reply({ status: 201, data: { id: 1 } }), + + // Backbeat: createDeployment + createDeploymentStatus + moctokit.rest.repos.createDeployment({ + owner: "scality", + repo: "backbeat", + ref: "9.3.0", + ...commonDeploymentParams, + }).reply({ status: 201, data: { id: 102 } }), + + moctokit.rest.repos.createDeploymentStatus({ + owner: "scality", + repo: "backbeat", + deployment_id: 102, + state: "in_progress", + log_url: "https://github.com/scality/zenko/actions/runs/1", + description: "Zenko CI running", + }).reply({ status: 201, data: { id: 2 } }), + ], + bind: true, + }); + + const parseStep = result.find(r => r.name?.includes("Parse component repos")); + expect(parseStep).toBeDefined(); + expect(parseStep?.status).toBe(0); + + const deployStep = result.find(r => r.name?.includes("Create or update deployments")); + expect(deployStep).toBeDefined(); + expect(deployStep?.status).toBe(0); + }); +}); diff --git a/tests/workflows/create-deployments.spec.ts b/tests/workflows/create-deployments.spec.ts new file mode 100644 index 0000000000..8989db1781 --- /dev/null +++ b/tests/workflows/create-deployments.spec.ts @@ -0,0 +1,207 @@ +import sinon from 'sinon'; + +const { findOrCreateDeployment, createDeployments } = require('../../.github/actions/create-component-deployments/create-deployments'); + +function makeMockCore() { + return { + info: sinon.stub(), + warning: sinon.stub(), + startGroup: sinon.stub(), + endGroup: sinon.stub(), + }; +} + +function makeMockGithub(overrides: Record = {}) { + return { + rest: { + repos: { + createDeployment: overrides.createDeployment ?? sinon.stub().resolves({ data: { id: 42 } }), + listDeployments: overrides.listDeployments ?? sinon.stub().resolves({ data: [] }), + createDeploymentStatus: overrides.createDeploymentStatus ?? sinon.stub().resolves({}), + }, + }, + }; +} + +const deploymentParams = { + owner: 'scality', + repo: 'sorbet', + ref: 'v1.2.2', + environment: 'zenko/development/2.11', + description: 'Zenko CI running', + transient: true, +}; + +const baseParams = { + environment: 'zenko/development/2.11', + transient: true, + logUrl: 'https://github.com/scality/zenko/actions/runs/123', + description: 'Zenko CI running', +}; + +describe('findOrCreateDeployment', () => { + it('creates directly when createOnly is true', async () => { + const github = makeMockGithub(); + + const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: true }); + + expect(id).toBe(42); + expect(github.rest.repos.listDeployments.called).toBe(false); + expect(github.rest.repos.createDeployment.calledOnce).toBe(true); + const call = github.rest.repos.createDeployment.firstCall.args[0]; + expect(call).toMatchObject({ + owner: 'scality', + repo: 'sorbet', + ref: 'v1.2.2', + environment: 'zenko/development/2.11', + transient_environment: true, + auto_merge: false, + required_contexts: [], + production_environment: false, + }); + }); + + it('returns existing deployment when found', async () => { + const github = makeMockGithub({ + listDeployments: sinon.stub().resolves({ data: [{ id: 99 }] }), + }); + + const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: false }); + + expect(id).toBe(99); + expect(github.rest.repos.createDeployment.called).toBe(false); + }); + + it('creates when no existing deployment found', async () => { + const github = makeMockGithub({ + listDeployments: sinon.stub().resolves({ data: [] }), + createDeployment: sinon.stub().resolves({ data: { id: 77 } }), + }); + + const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: false }); + + expect(id).toBe(77); + expect(github.rest.repos.listDeployments.calledOnce).toBe(true); + expect(github.rest.repos.createDeployment.calledOnce).toBe(true); + }); + + it('passes transient_environment: false for permanent deployments', async () => { + const github = makeMockGithub(); + + await findOrCreateDeployment(github, { ...deploymentParams, transient: false, createOnly: true }); + + const call = github.rest.repos.createDeployment.firstCall.args[0]; + expect(call.transient_environment).toBe(false); + }); +}); + +describe('createDeployments', () => { + it('creates deployment and sets in_progress status', async () => { + const github = makeMockGithub(); + const core = makeMockCore(); + + await createDeployments({ + github, core, + components: [{ repo: 'scality/sorbet', ref: 'v1.2.2' }], + ...baseParams, + status: 'in_progress', + }); + + // in_progress skips lookup, creates directly + expect(github.rest.repos.listDeployments.called).toBe(false); + expect(github.rest.repos.createDeployment.calledOnce).toBe(true); + expect(github.rest.repos.createDeploymentStatus.calledOnce).toBe(true); + const statusCall = github.rest.repos.createDeploymentStatus.firstCall.args[0]; + expect(statusCall.deployment_id).toBe(42); + expect(statusCall.state).toBe('in_progress'); + }); + + it('finds existing deployment on success update', async () => { + const github = makeMockGithub({ + listDeployments: sinon.stub().resolves({ data: [{ id: 99 }] }), + createDeploymentStatus: sinon.stub().resolves({}), + createDeployment: sinon.stub(), + }); + const core = makeMockCore(); + + await createDeployments({ + github, core, + components: [{ repo: 'scality/sorbet', ref: 'v1.2.2' }], + ...baseParams, + status: 'success', + description: 'Zenko CI passed', + }); + + expect(github.rest.repos.createDeployment.called).toBe(false); + expect(github.rest.repos.createDeploymentStatus.calledOnce).toBe(true); + const statusCall = github.rest.repos.createDeploymentStatus.firstCall.args[0]; + expect(statusCall.deployment_id).toBe(99); + expect(statusCall.state).toBe('success'); + }); + + it('creates new deployment if none found on failure update', async () => { + const github = makeMockGithub({ + listDeployments: sinon.stub().resolves({ data: [] }), + createDeployment: sinon.stub().resolves({ data: { id: 77 } }), + createDeploymentStatus: sinon.stub().resolves({}), + }); + const core = makeMockCore(); + + await createDeployments({ + github, core, + components: [{ repo: 'scality/backbeat', ref: '9.3.0' }], + ...baseParams, + status: 'failure', + }); + + expect(github.rest.repos.createDeployment.calledOnce).toBe(true); + const statusCall = github.rest.repos.createDeploymentStatus.firstCall.args[0]; + expect(statusCall.deployment_id).toBe(77); + expect(statusCall.state).toBe('failure'); + }); + + it('processes multiple components', async () => { + const github = makeMockGithub(); + const core = makeMockCore(); + + await createDeployments({ + github, core, + components: [ + { repo: 'scality/sorbet', ref: 'v1.2.2' }, + { repo: 'scality/backbeat', ref: '9.3.0' }, + { repo: 'scality/cloudserver', ref: '9.3.4' }, + ], + ...baseParams, + status: 'in_progress', + }); + + expect(github.rest.repos.createDeployment.callCount).toBe(3); + expect(github.rest.repos.createDeploymentStatus.callCount).toBe(3); + }); + + it('continues on individual failure and reports count', async () => { + const createDeployment = sinon.stub(); + createDeployment.onFirstCall().rejects(new Error('Not found')); + createDeployment.onSecondCall().resolves({ data: { id: 42 } }); + + const github = makeMockGithub({ + createDeployment, + createDeploymentStatus: sinon.stub().resolves({}), + }); + const core = makeMockCore(); + + const errors = await createDeployments({ + github, core, + components: [ + { repo: 'scality/sorbet', ref: 'v1.2.2' }, + { repo: 'scality/backbeat', ref: '9.3.0' }, + ], + ...baseParams, + status: 'in_progress', + }); + + expect(errors).toBe(1); + expect(github.rest.repos.createDeploymentStatus.calledOnce).toBe(true); + expect(core.warning.called).toBe(true); + }); +}); diff --git a/tests/workflows/jest.config.ts b/tests/workflows/jest.config.ts index 488ce53aeb..493349789f 100644 --- a/tests/workflows/jest.config.ts +++ b/tests/workflows/jest.config.ts @@ -7,6 +7,7 @@ const jestConfig: Config.InitialOptions = { }, clearMocks: true, resetMocks: true, + moduleDirectories: ['node_modules', '/node_modules'], maxWorkers: 1, testTimeout: 120000, }; diff --git a/tests/workflows/package.json b/tests/workflows/package.json index 69ff6e9477..7b102e3a1e 100644 --- a/tests/workflows/package.json +++ b/tests/workflows/package.json @@ -12,9 +12,11 @@ "@kie/mock-github": "^2.0.1", "@octokit/rest": "^19.0.4", "@types/jest": "^29.1.2", + "@types/js-yaml": "^4.0.9", "@types/node": "^18.8.5", "@types/sinon": "^10.0.0", "jest": "^29.1.2", + "js-yaml": "^4.1.1", "sinon": "^15.0.0", "ts-jest": "^29.0.3", "ts-node": "^10.9.1", diff --git a/tests/workflows/parse-deps.spec.ts b/tests/workflows/parse-deps.spec.ts new file mode 100644 index 0000000000..a9954cdcc0 --- /dev/null +++ b/tests/workflows/parse-deps.spec.ts @@ -0,0 +1,69 @@ +import path from 'path'; + +const { parseDeps } = require('../../.github/actions/create-component-deployments/parse-deps'); + +const depsFile = path.join(__dirname, '../../solution/deps.yaml'); + +describe('parseDeps', () => { + it('extracts scality components from deps.yaml', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + + expect(components.length).toBeGreaterThan(0); + + // Every component should be a scality repo (but not scality/zenko) + for (const c of components) { + expect(c.repo).toMatch(/^scality\//); + expect(c.repo).not.toBe('scality/zenko'); + expect(c.ref).toBeTruthy(); + } + }); + + it('includes known components', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + const repos = components.map((c: { repo: string }) => c.repo); + + expect(repos).toContain('scality/sorbet'); + expect(repos).toContain('scality/backbeat'); + expect(repos).toContain('scality/cloudserver'); + }); + + it('filters out self-repo', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + const repos = components.map((c: { repo: string }) => c.repo); + + expect(repos).not.toContain('scality/zenko'); + }); + + it('deduplicates by repo+ref', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + const keys = components.map((c: { repo: string; ref: string }) => `${c.repo} ${c.ref}`); + const unique = new Set(keys); + + expect(keys.length).toBe(unique.size); + }); + + it('returns repo short names for token scoping', () => { + const { repos } = parseDeps(depsFile, 'scality/zenko'); + + expect(repos.length).toBeGreaterThan(0); + for (const r of repos) { + expect(r).not.toContain('/'); + } + expect(repos).toContain('sorbet'); + }); + + it('excludes non-scality registries', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + const repos = components.map((c: { repo: string }) => c.repo); + + expect(repos).not.toContain('oliver006/redis_exporter'); + expect(repos).not.toContain('seglo/kafka-lag-exporter'); + }); + + it('returns empty for a non-existent self-repo matching nothing', () => { + const { components } = parseDeps(depsFile, 'scality/nonexistent'); + const repos = components.map((c: { repo: string }) => c.repo); + + expect(repos).toContain('scality/zenko'); + }); +}); diff --git a/tests/workflows/test-create-component-deployments.yaml b/tests/workflows/test-create-component-deployments.yaml new file mode 100644 index 0000000000..03d1d30827 --- /dev/null +++ b/tests/workflows/test-create-component-deployments.yaml @@ -0,0 +1,24 @@ +--- +name: Test Create Component Deployments +on: workflow_dispatch +jobs: + test-deployments: + runs-on: ubuntu-latest + steps: + - name: Install js-yaml globally + run: | + npm install -g js-yaml + echo "NODE_PATH=$(npm root -g)" >> "$GITHUB_ENV" + + - name: Create component deployments + uses: ./.github/actions/create-component-deployments + with: + github-token: ${{ env.GITHUB_TOKEN }} + app-id: 12345 + app-private-key: ${{ secrets.APP_PRIVATE_KEY }} + deps-file: solution/deps.yaml + environment: zenko/development/2.11 + status: in_progress + transient: "true" + log-url: https://github.com/scality/zenko/actions/runs/1 + description: Zenko CI running diff --git a/tests/workflows/test-deps.yaml b/tests/workflows/test-deps.yaml new file mode 100644 index 0000000000..689da0b0ee --- /dev/null +++ b/tests/workflows/test-deps.yaml @@ -0,0 +1,15 @@ +sorbet: + sourceRegistry: ghcr.io/scality + image: sorbet + tag: v1.2.2 +backbeat: + sourceRegistry: ghcr.io/scality + image: backbeat + tag: 9.3.0 +kafka: + sourceRegistry: ghcr.io/scality/zenko + image: kafka + tag: 2.13-3.1.2 +redis: + image: redis + tag: 7.2.11 diff --git a/tests/workflows/yarn.lock b/tests/workflows/yarn.lock index 63e5e8b9fa..feb19267ce 100644 --- a/tests/workflows/yarn.lock +++ b/tests/workflows/yarn.lock @@ -831,6 +831,11 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/node@*": version "22.13.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.9.tgz#5d9a8f7a975a5bd3ef267352deb96fb13ec02eca" @@ -953,6 +958,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -2264,6 +2274,13 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + jsesc@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" From ec81c2da194153557bc570b6c93f9221c175a2d3 Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Fri, 10 Apr 2026 12:51:42 +0200 Subject: [PATCH 2/8] Resolve playground images Issue: ZENKO-5132 --- .../create-component-deployments/action.yaml | 5 +- .../create-deployments.js | 143 ++++- .../parse-deps.js | 29 +- .../create-component-deployments.spec.ts | 1 + tests/workflows/create-deployments.spec.ts | 502 ++++++++++++------ tests/workflows/parse-deps.spec.ts | 179 ++++--- tests/workflows/test-deps.yaml | 4 + tests/workflows/tsconfig.json | 6 +- 8 files changed, 628 insertions(+), 241 deletions(-) diff --git a/.github/actions/create-component-deployments/action.yaml b/.github/actions/create-component-deployments/action.yaml index 233e9b943b..1ec2f48ba3 100644 --- a/.github/actions/create-component-deployments/action.yaml +++ b/.github/actions/create-component-deployments/action.yaml @@ -2,8 +2,9 @@ name: Manage Component Deployments description: > Create or update GitHub Deployments on scality component repos referenced in deps.yaml, providing integration visibility. - Generates a minimally-scoped token (deployments:write only, + Generates a minimally-scoped token (deployments:write + packages:read, limited to the exact repos found in deps.yaml). + Falls back to OCI manifest annotations when image tags are not valid git refs. inputs: app-id: @@ -70,6 +71,7 @@ runs: owner: ${{ github.repository_owner }} repositories: ${{ steps.parse.outputs.repos }} permission-deployments: write + permission-packages: read - name: Create or update deployments if: steps.parse.outputs.components != '' @@ -88,4 +90,5 @@ runs: transient: ${{ inputs.transient }}, logUrl: `${{ inputs.log-url }}`, description: `${{ inputs.description }}`, + token: `${{ steps.app-token.outputs.token }}`, }); diff --git a/.github/actions/create-component-deployments/create-deployments.js b/.github/actions/create-component-deployments/create-deployments.js index 7486e6cb2e..c943313562 100644 --- a/.github/actions/create-component-deployments/create-deployments.js +++ b/.github/actions/create-component-deployments/create-deployments.js @@ -3,8 +3,77 @@ /** * @typedef {import('@octokit/rest').Octokit} Octokit * @typedef {{ info: (msg: string) => void, warning: (msg: string) => void, startGroup: (name: string) => void, endGroup: () => void }} Core + * @typedef {{ repo: string, ref: string, image: string }} Component + * @typedef {{ token: string, environment: string, description: string, transient: boolean, createOnly: boolean }} DeploymentParams */ +const GHCR_REGISTRY = 'https://ghcr.io'; + +/** + * Fetch OCI image annotations from ghcr.io to resolve repo and git ref. + * + * Looks for standard OCI annotations: + * - org.opencontainers.image.revision → git SHA + * - org.opencontainers.image.source → repo URL + * + * @param {string} image - e.g. "scality/playground/my-image" + * @param {string} tag + * @param {string} token - Bearer token with packages:read + * @returns {Promise<{repo: string, ref: string} | null>} + */ +async function resolveFromManifest(image, tag, token) { + // Get a scoped token from ghcr.io (Basic auth required for private images) + const authResp = await fetch(`${GHCR_REGISTRY}/token?scope=repository:${image}:pull`, { + headers: { Authorization: `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` }, + }); + if (!authResp.ok) { + return null; + } + const { token: registryToken } = await authResp.json(); + + const headers = { + Authorization: `Bearer ${registryToken}`, + Accept: [ + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json', + ].join(', '), + }; + + // Fetch manifest + const manifestResp = await fetch(`${GHCR_REGISTRY}/v2/${image}/manifests/${tag}`, { headers }); + if (!manifestResp.ok) { + return null; + } + const manifest = await manifestResp.json(); + + // Check manifest annotations first (OCI image manifest) + let annotations = manifest.annotations || {}; + + // If no revision in manifest annotations, check the config blob + if (!annotations['org.opencontainers.image.revision'] && manifest.config) { + const configResp = await fetch(`${GHCR_REGISTRY}/v2/${image}/blobs/${manifest.config.digest}`, { headers }); + if (configResp.ok) { + const config = await configResp.json(); + annotations = config.config?.Labels || {}; + } + } + + const ref = annotations['org.opencontainers.image.revision']; + if (!ref) { + return null; + } + + // Derive repo from source annotation or image path + const source = annotations['org.opencontainers.image.source'] || ''; + let repo = ''; + const ghMatch = source.match(/github\.com\/([^/]+\/[^/]+)/); + if (ghMatch) { + repo = ghMatch[1].replace(/\.git$/, ''); + } + + return { repo, ref }; +} + /** * Find an existing deployment or create a new one. * @@ -45,33 +114,76 @@ async function findOrCreateDeployment(github, { owner, repo, ref, environment, d return data.id; } +/** + * Resolve repo/ref from manifest if needed, then find or create a deployment. + * If findOrCreateDeployment fails with 409/422 and repo was not yet resolved, + * resolves from the manifest and retries once. + * + * @param {Octokit} github + * @param {Core} core + * @param {Function} resolve + * @param {Component} component + * @param {DeploymentParams} deployParams + * @returns {Promise<{component: Component, deploymentId: number}>} + */ +async function resolveDeployment(github, core, resolve, { repo, ref, image }, deployParams) { + const { token, environment, description, transient, createOnly } = deployParams; + + // Resolve repo/ref from manifest if not provided + if (!repo) { + const resolved = await resolve(image, ref, token); + if (!resolved?.repo) { + throw new Error(`Could not resolve repo for ${image}:${ref}`); + } + + repo = resolved.repo; + ref = resolved.ref || ref; + } + + const [owner, repoName] = repo.split('/'); + try { + const deploymentId = await findOrCreateDeployment(github, { + owner, repo: repoName, ref, environment, description, transient, createOnly, + }); + + return { component: { repo, ref, image }, deploymentId }; + } catch (/** @type {any} */ err) { + if (canRetry && (err.status === 409 || err.status === 422)) { + core.info(`Ref "${ref}" not found on ${repo}, checking image manifest...`); + return resolveDeployment(github, core, resolve, { repo: '', ref, image }, deployParams); + } + + throw err; + } +} + /** * Create or update GitHub Deployments on component repos. * * @param {object} params * @param {Octokit} params.github - Octokit instance * @param {Core} params.core - GitHub Actions core - * @param {Array<{repo: string, ref: string}>} params.components - Parsed component list + * @param {Array} params.components - Parsed component list * @param {string} params.environment - Deployment environment name * @param {string} params.status - Deployment status (in_progress, success, failure) * @param {boolean} params.transient - Whether deployments are transient * @param {string} params.logUrl - URL to link from the deployment status * @param {string} params.description - Human-readable description + * @param {string} params.token - GitHub token with packages:read for manifest lookups */ -async function createDeployments({ github, core, components, environment, status, transient, logUrl, description }) { +async function createDeployments({ github, core, components, environment, status, transient, logUrl, description, token }) { + const deployParams = { token, environment, description, transient, createOnly: status === 'in_progress' }; let errors = 0; - for (const { repo, ref } of components) { - const [owner, repoName] = repo.split('/'); - core.startGroup(`${repo} @ ${ref}`); + for (const component of components) { + core.startGroup(`${component.repo || component.image}:${component.ref}`); try { - const deploymentId = await findOrCreateDeployment(github, { - owner, repo: repoName, ref, - environment, description, transient, - createOnly: status === 'in_progress', - }); - core.info(`Deployment ${deploymentId}`); + const { component: resolved, deploymentId } = await resolveDeployment( + github, core, resolveFromManifest, component, deployParams, + ); + const [owner, repoName] = resolved.repo.split('/'); + core.info(`Resolved to ${resolved.repo} @ ${resolved.ref}`); await github.rest.repos.createDeploymentStatus({ owner, repo: repoName, @@ -80,9 +192,10 @@ async function createDeployments({ github, core, components, environment, status log_url: logUrl, description, }); - core.info(`Status: ${status}`); + + core.info(`Deployment ${deploymentId}, status: ${status}`); } catch (/** @type {any} */ err) { - core.warning(`Failed on ${repo}: ${err.message}`); + core.warning(`Failed on ${component.repo || component.image}: ${err.message}`); errors++; } @@ -90,7 +203,7 @@ async function createDeployments({ github, core, components, environment, status } if (errors > 0) { - core.warning(`${errors} deployment(s) failed (non-fatal)`); + core.warning(`${errors} deployment(s) failed (non-fatal)`); } return errors; @@ -98,5 +211,7 @@ async function createDeployments({ github, core, components, environment, status module.exports = { findOrCreateDeployment, + resolveDeployment, + resolveFromManifest, createDeployments, }; diff --git a/.github/actions/create-component-deployments/parse-deps.js b/.github/actions/create-component-deployments/parse-deps.js index d0cdeb4ebf..ef1e16ff28 100644 --- a/.github/actions/create-component-deployments/parse-deps.js +++ b/.github/actions/create-component-deployments/parse-deps.js @@ -3,11 +3,20 @@ const fs = require('fs'); const yaml = require('js-yaml'); /** - * Parse deps.yaml and extract unique {repo, ref} pairs for ghcr.io/scality/* images. + * Strip @sha256:... digest suffix from a tag. + * @param {string} tag + * @returns {string} + */ +function stripDigest(tag) { + return tag.replace(/@sha256:[0-9a-f]+$/i, ''); +} + +/** + * Parse deps.yaml and extract component info for ghcr.io/scality/* images. * * @param {string} depsFile - Path to deps.yaml * @param {string} selfRepo - The current repo (org/name) to exclude from results - * @returns {{ components: Array<{repo: string, ref: string}>, repos: string[] }} + * @returns {{ components: Array<{repo: string, ref: string, image: string}>, repos: string[] }} */ function parseDeps(depsFile, selfRepo) { const deps = yaml.load(fs.readFileSync(depsFile, 'utf8')); @@ -30,19 +39,27 @@ function parseDeps(depsFile, selfRepo) { continue; } - const key = `${repo} ${entry.tag}`; + const tag = stripDigest(entry.tag); + const key = `${fullPath} ${tag}`; if (seen.has(key)) { continue; } seen.add(key); - components.push({ repo, ref: entry.tag }); + + components.push({ + repo: repo === 'scality/playground' ? '' : repo, + ref: tag, + image: fullPath, + }); } // Unique repo short names (without org/) for token scoping - const repos = [...new Set(components.map(c => c.repo.split('/')[1]))]; + const repos = [...new Set( + components.map(c => c.repo.split('/')[1]).filter(Boolean), + )]; return { components, repos }; } -module.exports = { parseDeps }; +module.exports = { parseDeps, stripDigest }; diff --git a/tests/workflows/create-component-deployments.spec.ts b/tests/workflows/create-component-deployments.spec.ts index 8bd667f7c4..1ccc8e37e7 100644 --- a/tests/workflows/create-component-deployments.spec.ts +++ b/tests/workflows/create-component-deployments.spec.ts @@ -124,6 +124,7 @@ describe("create-component-deployments action", () => { // test-deps.yaml has 2 scality components (sorbet + backbeat) // kafka is scality/zenko (filtered as self-repo), redis has no scality registry + // playground-sandbox has empty repo, manifest resolution will fail (non-fatal) // Sorbet: createDeployment + createDeploymentStatus moctokit.rest.repos.createDeployment({ diff --git a/tests/workflows/create-deployments.spec.ts b/tests/workflows/create-deployments.spec.ts index 8989db1781..6ca120a930 100644 --- a/tests/workflows/create-deployments.spec.ts +++ b/tests/workflows/create-deployments.spec.ts @@ -1,207 +1,399 @@ import sinon from 'sinon'; -const { findOrCreateDeployment, createDeployments } = require('../../.github/actions/create-component-deployments/create-deployments'); +const { findOrCreateDeployment, resolveDeployment, createDeployments, resolveFromManifest } = require('../../.github/actions/create-component-deployments/create-deployments'); function makeMockCore() { - return { - info: sinon.stub(), - warning: sinon.stub(), - startGroup: sinon.stub(), - endGroup: sinon.stub(), - }; + return { + info: sinon.stub(), + warning: sinon.stub(), + startGroup: sinon.stub(), + endGroup: sinon.stub(), + }; } function makeMockGithub(overrides: Record = {}) { - return { - rest: { - repos: { - createDeployment: overrides.createDeployment ?? sinon.stub().resolves({ data: { id: 42 } }), - listDeployments: overrides.listDeployments ?? sinon.stub().resolves({ data: [] }), - createDeploymentStatus: overrides.createDeploymentStatus ?? sinon.stub().resolves({}), - }, - }, - }; + return { + rest: { + repos: { + createDeployment: overrides.createDeployment ?? sinon.stub().resolves({ data: { id: 42 } }), + listDeployments: overrides.listDeployments ?? sinon.stub().resolves({ data: [] }), + createDeploymentStatus: overrides.createDeploymentStatus ?? sinon.stub().resolves({}), + }, + }, + }; } const deploymentParams = { - owner: 'scality', - repo: 'sorbet', - ref: 'v1.2.2', - environment: 'zenko/development/2.11', - description: 'Zenko CI running', - transient: true, + owner: 'scality', + repo: 'sorbet', + ref: 'v1.2.2', + environment: 'zenko/development/2.11', + description: 'Zenko CI running', + transient: true, }; const baseParams = { - environment: 'zenko/development/2.11', - transient: true, - logUrl: 'https://github.com/scality/zenko/actions/runs/123', - description: 'Zenko CI running', + environment: 'zenko/development/2.11', + transient: true, + logUrl: 'https://github.com/scality/zenko/actions/runs/123', + description: 'Zenko CI running', + token: 'fake-token', }; -describe('findOrCreateDeployment', () => { - it('creates directly when createOnly is true', async () => { - const github = makeMockGithub(); - - const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: true }); - - expect(id).toBe(42); - expect(github.rest.repos.listDeployments.called).toBe(false); - expect(github.rest.repos.createDeployment.calledOnce).toBe(true); - const call = github.rest.repos.createDeployment.firstCall.args[0]; - expect(call).toMatchObject({ - owner: 'scality', - repo: 'sorbet', - ref: 'v1.2.2', - environment: 'zenko/development/2.11', - transient_environment: true, - auto_merge: false, - required_contexts: [], - production_environment: false, +describe('resolveDeployment', () => { + const deployParams = { + token: 'fake-token', + environment: 'zenko/dev', + description: 'test', + transient: true, + createOnly: true, + }; + const component = { repo: 'scality/sorbet', ref: 'v1.0.0', image: 'scality/sorbet' }; + + it('creates deployment and returns resolved component', async () => { + const github = makeMockGithub(); + const core = makeMockCore(); + const resolve = sinon.stub(); + + const result = await resolveDeployment(github, core, resolve, component, deployParams); + + expect(result).toEqual({ component, deploymentId: 42 }); + expect(resolve.called).toBe(false); }); - }); - it('returns existing deployment when found', async () => { - const github = makeMockGithub({ - listDeployments: sinon.stub().resolves({ data: [{ id: 99 }] }), + it('resolves from manifest when repo is empty', async () => { + const github = makeMockGithub(); + const core = makeMockCore(); + const resolve = sinon.stub().resolves({ repo: 'scality/sorbet', ref: 'deadbeef' }); + + const result = await resolveDeployment(github, core, resolve, + { repo: '', ref: 'some-tag', image: 'scality/playground/sandbox' }, + deployParams, + ); + + expect(result.component).toEqual({ repo: 'scality/sorbet', ref: 'deadbeef', image: 'scality/playground/sandbox' }); + expect(result.deploymentId).toBe(42); + expect(resolve.calledOnce).toBe(true); + const call = github.rest.repos.createDeployment.firstCall.args[0]; + expect(call.owner).toBe('scality'); + expect(call.repo).toBe('sorbet'); + expect(call.ref).toBe('deadbeef'); }); - const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: false }); + it('throws when manifest resolution fails to return a repo', async () => { + const github = makeMockGithub(); + const core = makeMockCore(); + const resolve = sinon.stub().resolves(null); - expect(id).toBe(99); - expect(github.rest.repos.createDeployment.called).toBe(false); - }); + await expect( + resolveDeployment(github, core, resolve, { repo: '', ref: 'tag', image: 'scality/playground/x' }, deployParams) + ).rejects.toThrow('Could not resolve repo'); + }); - it('creates when no existing deployment found', async () => { - const github = makeMockGithub({ - listDeployments: sinon.stub().resolves({ data: [] }), - createDeployment: sinon.stub().resolves({ data: { id: 77 } }), + it('retries via manifest on 422 and returns resolved component', async () => { + const err = Object.assign(new Error('Unprocessable'), { status: 422 }); + const createDeployment = sinon.stub(); + createDeployment.onFirstCall().rejects(err); + createDeployment.onSecondCall().resolves({ data: { id: 77 } }); + const github = makeMockGithub({ createDeployment }); + const core = makeMockCore(); + const resolve = sinon.stub().resolves({ repo: 'scality/sorbet', ref: 'abc1234' }); + + const result = await resolveDeployment(github, core, resolve, component, deployParams); + + expect(result.component).toEqual({ repo: 'scality/sorbet', ref: 'abc1234', image: 'scality/sorbet' }); + expect(result.deploymentId).toBe(77); + expect(createDeployment.callCount).toBe(2); + expect(createDeployment.secondCall.args[0].ref).toBe('abc1234'); }); - const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: false }); + it('retries via manifest on 409 and returns resolved component', async () => { + const err = Object.assign(new Error('Conflict'), { status: 409 }); + const createDeployment = sinon.stub(); + createDeployment.onFirstCall().rejects(err); + createDeployment.onSecondCall().resolves({ data: { id: 55 } }); + const github = makeMockGithub({ createDeployment }); + const core = makeMockCore(); + const resolve = sinon.stub().resolves({ repo: 'scality/sorbet', ref: 'abc1234' }); - expect(id).toBe(77); - expect(github.rest.repos.listDeployments.calledOnce).toBe(true); - expect(github.rest.repos.createDeployment.calledOnce).toBe(true); - }); + const result = await resolveDeployment(github, core, resolve, component, deployParams); - it('passes transient_environment: false for permanent deployments', async () => { - const github = makeMockGithub(); + expect(result.deploymentId).toBe(55); + expect(createDeployment.callCount).toBe(2); + }); - await findOrCreateDeployment(github, { ...deploymentParams, transient: false, createOnly: true }); + it('throws non-422/409 errors directly', async () => { + const err = Object.assign(new Error('Server error'), { status: 500 }); + const github = makeMockGithub({ createDeployment: sinon.stub().rejects(err) }); + const core = makeMockCore(); + const resolve = sinon.stub(); - const call = github.rest.repos.createDeployment.firstCall.args[0]; - expect(call.transient_environment).toBe(false); + await expect( + resolveDeployment(github, core, resolve, component, deployParams) + ).rejects.toMatchObject({ status: 500 }); + expect(resolve.called).toBe(false); + }); }); + + describe('findOrCreateDeployment', () => { + it('creates directly when createOnly is true', async () => { + const github = makeMockGithub(); + + const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: true }); + + expect(id).toBe(42); + expect(github.rest.repos.listDeployments.called).toBe(false); + expect(github.rest.repos.createDeployment.calledOnce).toBe(true); + const call = github.rest.repos.createDeployment.firstCall.args[0]; + expect(call).toMatchObject({ + owner: 'scality', + repo: 'sorbet', + ref: 'v1.2.2', + environment: 'zenko/development/2.11', + transient_environment: true, + auto_merge: false, + required_contexts: [], + production_environment: false, + }); + }); + + it('returns existing deployment when found', async () => { + const github = makeMockGithub({ + listDeployments: sinon.stub().resolves({ data: [{ id: 99 }] }), + }); + + const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: false }); + + expect(id).toBe(99); + expect(github.rest.repos.createDeployment.called).toBe(false); + }); + + it('creates when no existing deployment found', async () => { + const github = makeMockGithub({ + listDeployments: sinon.stub().resolves({ data: [] }), + createDeployment: sinon.stub().resolves({ data: { id: 77 } }), + }); + + const id = await findOrCreateDeployment(github, { ...deploymentParams, createOnly: false }); + + expect(id).toBe(77); + expect(github.rest.repos.listDeployments.calledOnce).toBe(true); + expect(github.rest.repos.createDeployment.calledOnce).toBe(true); + }); + + it('passes transient_environment: false for permanent deployments', async () => { + const github = makeMockGithub(); + + await findOrCreateDeployment(github, { ...deploymentParams, transient: false, createOnly: true }); + + const call = github.rest.repos.createDeployment.firstCall.args[0]; + expect(call.transient_environment).toBe(false); + }); }); describe('createDeployments', () => { - it('creates deployment and sets in_progress status', async () => { - const github = makeMockGithub(); - const core = makeMockCore(); - - await createDeployments({ - github, core, - components: [{ repo: 'scality/sorbet', ref: 'v1.2.2' }], - ...baseParams, - status: 'in_progress', - }); - - // in_progress skips lookup, creates directly - expect(github.rest.repos.listDeployments.called).toBe(false); - expect(github.rest.repos.createDeployment.calledOnce).toBe(true); - expect(github.rest.repos.createDeploymentStatus.calledOnce).toBe(true); - const statusCall = github.rest.repos.createDeploymentStatus.firstCall.args[0]; - expect(statusCall.deployment_id).toBe(42); - expect(statusCall.state).toBe('in_progress'); - }); + it('calls resolveDeployment and sets deployment status', async () => { + const github = makeMockGithub(); + const core = makeMockCore(); + + await createDeployments({ + github, core, + components: [{ repo: 'scality/sorbet', ref: 'v1.2.2', image: 'scality/sorbet' }], + ...baseParams, + status: 'in_progress', + }); + + expect(github.rest.repos.createDeployment.calledOnce).toBe(true); + expect(github.rest.repos.createDeploymentStatus.calledOnce).toBe(true); + const statusCall = github.rest.repos.createDeploymentStatus.firstCall.args[0]; + expect(statusCall.deployment_id).toBe(42); + expect(statusCall.state).toBe('in_progress'); + expect(statusCall.log_url).toBe(baseParams.logUrl); + expect(statusCall.description).toBe(baseParams.description); + }); - it('finds existing deployment on success update', async () => { - const github = makeMockGithub({ - listDeployments: sinon.stub().resolves({ data: [{ id: 99 }] }), - createDeploymentStatus: sinon.stub().resolves({}), - createDeployment: sinon.stub(), + it('processes multiple components', async () => { + const github = makeMockGithub(); + const core = makeMockCore(); + + await createDeployments({ + github, core, + components: [ + { repo: 'scality/sorbet', ref: 'v1.2.2', image: 'scality/sorbet' }, + { repo: 'scality/backbeat', ref: '9.3.0', image: 'scality/backbeat' }, + { repo: 'scality/cloudserver', ref: '9.3.4', image: 'scality/cloudserver' }, + ], + ...baseParams, + status: 'in_progress', + }); + + expect(github.rest.repos.createDeployment.callCount).toBe(3); + expect(github.rest.repos.createDeploymentStatus.callCount).toBe(3); }); - const core = makeMockCore(); - await createDeployments({ - github, core, - components: [{ repo: 'scality/sorbet', ref: 'v1.2.2' }], - ...baseParams, - status: 'success', - description: 'Zenko CI passed', + it('continues on individual failure and reports count', async () => { + const createDeployment = sinon.stub(); + createDeployment.onFirstCall().rejects(new Error('Not found')); + createDeployment.onSecondCall().resolves({ data: { id: 42 } }); + + const github = makeMockGithub({ + createDeployment, + createDeploymentStatus: sinon.stub().resolves({}), + }); + const core = makeMockCore(); + + const errors = await createDeployments({ + github, core, + components: [ + { repo: 'scality/sorbet', ref: 'v1.2.2', image: 'scality/sorbet' }, + { repo: 'scality/backbeat', ref: '9.3.0', image: 'scality/backbeat' }, + ], + ...baseParams, + status: 'in_progress', + }); + + expect(errors).toBe(1); + expect(github.rest.repos.createDeploymentStatus.calledOnce).toBe(true); + expect(core.warning.called).toBe(true); }); +}); - expect(github.rest.repos.createDeployment.called).toBe(false); - expect(github.rest.repos.createDeploymentStatus.calledOnce).toBe(true); - const statusCall = github.rest.repos.createDeploymentStatus.firstCall.args[0]; - expect(statusCall.deployment_id).toBe(99); - expect(statusCall.state).toBe('success'); - }); +describe('resolveFromManifest', () => { + let fetchStub: sinon.SinonStub; + + function mockOk(body: object) { + return { ok: true, json: () => Promise.resolve(body) }; + } + function mockFail() { + return { ok: false }; + } - it('creates new deployment if none found on failure update', async () => { - const github = makeMockGithub({ - listDeployments: sinon.stub().resolves({ data: [] }), - createDeployment: sinon.stub().resolves({ data: { id: 77 } }), - createDeploymentStatus: sinon.stub().resolves({}), + beforeEach(() => { + fetchStub = sinon.stub(globalThis as any, 'fetch'); }); - const core = makeMockCore(); - await createDeployments({ - github, core, - components: [{ repo: 'scality/backbeat', ref: '9.3.0' }], - ...baseParams, - status: 'failure', + afterEach(() => { + fetchStub.restore(); }); - expect(github.rest.repos.createDeployment.calledOnce).toBe(true); - const statusCall = github.rest.repos.createDeploymentStatus.firstCall.args[0]; - expect(statusCall.deployment_id).toBe(77); - expect(statusCall.state).toBe('failure'); - }); + it('returns null when auth request fails', async () => { + fetchStub.resolves(mockFail()); - it('processes multiple components', async () => { - const github = makeMockGithub(); - const core = makeMockCore(); + const result = await resolveFromManifest('scality/sorbet', 'v1.0.0', 'gh-token'); - await createDeployments({ - github, core, - components: [ - { repo: 'scality/sorbet', ref: 'v1.2.2' }, - { repo: 'scality/backbeat', ref: '9.3.0' }, - { repo: 'scality/cloudserver', ref: '9.3.4' }, - ], - ...baseParams, - status: 'in_progress', + expect(result).toBeNull(); + expect(fetchStub.callCount).toBe(1); }); - expect(github.rest.repos.createDeployment.callCount).toBe(3); - expect(github.rest.repos.createDeploymentStatus.callCount).toBe(3); - }); + it('returns null when manifest request fails', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'reg-token' })); + fetchStub.onSecondCall().resolves(mockFail()); - it('continues on individual failure and reports count', async () => { - const createDeployment = sinon.stub(); - createDeployment.onFirstCall().rejects(new Error('Not found')); - createDeployment.onSecondCall().resolves({ data: { id: 42 } }); + const result = await resolveFromManifest('scality/sorbet', 'v1.0.0', 'gh-token'); - const github = makeMockGithub({ - createDeployment, - createDeploymentStatus: sinon.stub().resolves({}), + expect(result).toBeNull(); + expect(fetchStub.callCount).toBe(2); }); - const core = makeMockCore(); - const errors = await createDeployments({ - github, core, - components: [ - { repo: 'scality/sorbet', ref: 'v1.2.2' }, - { repo: 'scality/backbeat', ref: '9.3.0' }, - ], - ...baseParams, - status: 'in_progress', + it('resolves from OCI manifest annotations', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'reg-token' })); + fetchStub.onSecondCall().resolves(mockOk({ + annotations: { + 'org.opencontainers.image.revision': 'abc1234', + 'org.opencontainers.image.source': 'https://github.com/scality/sorbet', + }, + })); + + const result = await resolveFromManifest('scality/sorbet', 'v1.0.0', 'gh-token'); + + expect(result).toEqual({ repo: 'scality/sorbet', ref: 'abc1234' }); + expect(fetchStub.callCount).toBe(2); // no blob fetch needed }); - expect(errors).toBe(1); - expect(github.rest.repos.createDeploymentStatus.calledOnce).toBe(true); - expect(core.warning.called).toBe(true); - }); + it('falls back to config blob when manifest has no annotations', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'reg-token' })); + fetchStub.onSecondCall().resolves(mockOk({ + config: { digest: 'sha256:deadbeef' }, + })); + fetchStub.onThirdCall().resolves(mockOk({ + config: { + Labels: { + 'org.opencontainers.image.revision': 'deadbeef', + 'org.opencontainers.image.source': 'https://github.com/scality/cloudserver', + }, + }, + })); + + const result = await resolveFromManifest('scality/cloudserver', 'latest', 'gh-token'); + + expect(result).toEqual({ repo: 'scality/cloudserver', ref: 'deadbeef' }); + expect(fetchStub.callCount).toBe(3); + }); + + it('returns null when no revision found in manifest or blob', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'reg-token' })); + fetchStub.onSecondCall().resolves(mockOk({ + config: { digest: 'sha256:deadbeef' }, + })); + fetchStub.onThirdCall().resolves(mockOk({ + config: { Labels: {} }, + })); + + const result = await resolveFromManifest('scality/sorbet', 'v1.0.0', 'gh-token'); + + expect(result).toBeNull(); + }); + + it('strips .git suffix from source annotation', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'reg-token' })); + fetchStub.onSecondCall().resolves(mockOk({ + annotations: { + 'org.opencontainers.image.revision': 'abc1234', + 'org.opencontainers.image.source': 'https://github.com/scality/backbeat.git', + }, + })); + + const result = await resolveFromManifest('scality/backbeat', 'v9.0.0', 'gh-token'); + + expect(result).toEqual({ repo: 'scality/backbeat', ref: 'abc1234' }); + }); + + it('returns empty repo when source annotation is missing', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'reg-token' })); + fetchStub.onSecondCall().resolves(mockOk({ + annotations: { + 'org.opencontainers.image.revision': 'abc1234', + }, + })); + + const result = await resolveFromManifest('scality/sorbet', 'v1.0.0', 'gh-token'); + + expect(result).toEqual({ repo: '', ref: 'abc1234' }); + }); + + it('uses Basic auth with x-access-token for the token request', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'reg-token' })); + fetchStub.onSecondCall().resolves(mockOk({ + annotations: { 'org.opencontainers.image.revision': 'abc1234' }, + })); + + await resolveFromManifest('scality/sorbet', 'v1.0.0', 'my-secret-token'); + + const authCall = fetchStub.firstCall; + const expectedAuth = `Basic ${Buffer.from('x-access-token:my-secret-token').toString('base64')}`; + expect(authCall.args[0]).toContain('/token?scope=repository:scality/sorbet:pull'); + expect(authCall.args[1].headers.Authorization).toBe(expectedAuth); + }); + + it('uses registry token as Bearer for manifest and blob fetches', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'scoped-reg-token' })); + fetchStub.onSecondCall().resolves(mockOk({ + annotations: { 'org.opencontainers.image.revision': 'abc1234' }, + })); + + await resolveFromManifest('scality/sorbet', 'v1.0.0', 'gh-token'); + + const manifestCall = fetchStub.secondCall; + expect(manifestCall.args[1].headers.Authorization).toBe('Bearer scoped-reg-token'); + }); }); diff --git a/tests/workflows/parse-deps.spec.ts b/tests/workflows/parse-deps.spec.ts index a9954cdcc0..8476106f46 100644 --- a/tests/workflows/parse-deps.spec.ts +++ b/tests/workflows/parse-deps.spec.ts @@ -1,69 +1,124 @@ import path from 'path'; -const { parseDeps } = require('../../.github/actions/create-component-deployments/parse-deps'); +const { parseDeps, stripDigest } = require('../../.github/actions/create-component-deployments/parse-deps'); const depsFile = path.join(__dirname, '../../solution/deps.yaml'); describe('parseDeps', () => { - it('extracts scality components from deps.yaml', () => { - const { components } = parseDeps(depsFile, 'scality/zenko'); - - expect(components.length).toBeGreaterThan(0); - - // Every component should be a scality repo (but not scality/zenko) - for (const c of components) { - expect(c.repo).toMatch(/^scality\//); - expect(c.repo).not.toBe('scality/zenko'); - expect(c.ref).toBeTruthy(); - } - }); - - it('includes known components', () => { - const { components } = parseDeps(depsFile, 'scality/zenko'); - const repos = components.map((c: { repo: string }) => c.repo); - - expect(repos).toContain('scality/sorbet'); - expect(repos).toContain('scality/backbeat'); - expect(repos).toContain('scality/cloudserver'); - }); - - it('filters out self-repo', () => { - const { components } = parseDeps(depsFile, 'scality/zenko'); - const repos = components.map((c: { repo: string }) => c.repo); - - expect(repos).not.toContain('scality/zenko'); - }); - - it('deduplicates by repo+ref', () => { - const { components } = parseDeps(depsFile, 'scality/zenko'); - const keys = components.map((c: { repo: string; ref: string }) => `${c.repo} ${c.ref}`); - const unique = new Set(keys); - - expect(keys.length).toBe(unique.size); - }); - - it('returns repo short names for token scoping', () => { - const { repos } = parseDeps(depsFile, 'scality/zenko'); - - expect(repos.length).toBeGreaterThan(0); - for (const r of repos) { - expect(r).not.toContain('/'); - } - expect(repos).toContain('sorbet'); - }); - - it('excludes non-scality registries', () => { - const { components } = parseDeps(depsFile, 'scality/zenko'); - const repos = components.map((c: { repo: string }) => c.repo); - - expect(repos).not.toContain('oliver006/redis_exporter'); - expect(repos).not.toContain('seglo/kafka-lag-exporter'); - }); - - it('returns empty for a non-existent self-repo matching nothing', () => { - const { components } = parseDeps(depsFile, 'scality/nonexistent'); - const repos = components.map((c: { repo: string }) => c.repo); - - expect(repos).toContain('scality/zenko'); - }); + it('extracts scality components from deps.yaml', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + + expect(components.length).toBeGreaterThan(0); + + // Every component should be a scality repo (but not scality/zenko) + for (const c of components) { + expect(c.repo).toMatch(/^scality\//); + expect(c.repo).not.toBe('scality/zenko'); + expect(c.ref).toBeTruthy(); + expect(c.image).toBeTruthy(); + } + }); + + it('includes known components', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + const repos = components.map((c: { repo: string }) => c.repo); + + expect(repos).toContain('scality/sorbet'); + expect(repos).toContain('scality/backbeat'); + expect(repos).toContain('scality/cloudserver'); + }); + + it('filters out self-repo', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + const repos = components.map((c: { repo: string }) => c.repo); + + expect(repos).not.toContain('scality/zenko'); + }); + + it('filters out self-repo case-insensitively', () => { + const { components } = parseDeps(depsFile, 'scality/Zenko'); + const repos = components.map((c: { repo: string }) => c.repo.toLowerCase()); + + expect(repos).not.toContain('scality/zenko'); + }); + + it('deduplicates by image+ref', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + const keys = components.map((c: { image: string; ref: string }) => `${c.image} ${c.ref}`); + const unique = new Set(keys); + + expect(keys.length).toBe(unique.size); + }); + + it('excludes non-scality registries', () => { + const { components } = parseDeps(depsFile, 'scality/zenko'); + const repos = components.map((c: { repo: string }) => c.repo); + + expect(repos).not.toContain('oliver006/redis_exporter'); + expect(repos).not.toContain('seglo/kafka-lag-exporter'); + }); + + it('returns empty for a non-existent self-repo matching nothing', () => { + const { components } = parseDeps(depsFile, 'scality/nonexistent'); + const repos = components.map((c: { repo: string }) => c.repo); + + expect(repos).toContain('scality/zenko'); + }); + + it('strips @sha256: digest from tags', () => { + expect(stripDigest('v1.2.3@sha256:abc123def456')).toBe('v1.2.3'); + expect(stripDigest('9.3.0')).toBe('9.3.0'); + }); + + it('sets empty repo for playground images', () => { + const testDeps = path.join(__dirname, 'test-deps.yaml'); + const { components } = parseDeps(testDeps, 'scality/zenko'); + const playground = components.find((c: { image: string }) => c.image.includes('playground')); + + expect(playground).toBeDefined(); + expect(playground.repo).toBe(''); + expect(playground.image).toBe('scality/playground/my-sandbox'); + }); + + it('is non-empty and contains known short names', () => { + const { repos } = parseDeps(depsFile, 'scality/zenko'); + + expect(repos.length).toBeGreaterThan(0); + expect(repos).toContain('sorbet'); + expect(repos).toContain('backbeat'); + expect(repos).toContain('cloudserver'); + }); + + describe('repos array', () => { + it('contains only short names without org prefix', () => { + const { repos } = parseDeps(depsFile, 'scality/zenko'); + + for (const r of repos) { + expect(r).not.toContain('/'); + } + }); + + it('has no duplicates', () => { + const { repos } = parseDeps(depsFile, 'scality/zenko'); + + expect(repos.length).toBe(new Set(repos).size); + }); + + it('excludes empty strings (playground images are not scoped)', () => { + const { repos } = parseDeps(depsFile, 'scality/zenko'); + + expect(repos).not.toContain(''); + }); + + it('is consistent with components — every short name has a matching component', () => { + const { components, repos } = parseDeps(depsFile, 'scality/zenko'); + const componentShortNames = new Set( + components.map((c: { repo: string }) => c.repo.split('/')[1]).filter(Boolean), + ); + + for (const r of repos) { + expect(componentShortNames.has(r)).toBe(true); + } + }); + }); }); diff --git a/tests/workflows/test-deps.yaml b/tests/workflows/test-deps.yaml index 689da0b0ee..fdc6b5a245 100644 --- a/tests/workflows/test-deps.yaml +++ b/tests/workflows/test-deps.yaml @@ -10,6 +10,10 @@ kafka: sourceRegistry: ghcr.io/scality/zenko image: kafka tag: 2.13-3.1.2 +playground-sandbox: + sourceRegistry: ghcr.io/scality/playground + image: my-sandbox + tag: feat-branch-abc123 redis: image: redis tag: 7.2.11 diff --git a/tests/workflows/tsconfig.json b/tests/workflows/tsconfig.json index 0a03b6191b..65f1d087ec 100644 --- a/tests/workflows/tsconfig.json +++ b/tests/workflows/tsconfig.json @@ -42,10 +42,10 @@ ], }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ - // "types": [], /* Type declaration files to be included in compilation. */ + "typeRoots": ["./node_modules/@types"], /* List of folders to include type definitions from. */ + "types": ["jest", "node"], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ /* Source Map Options */ From 43ab930527864a55138d379eb3cf178434c22295 Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Sun, 5 Apr 2026 00:22:54 +0200 Subject: [PATCH 3/8] In PR, publish deployment only when deps change To reduce noise, publish (transient) deployments only for components which are updated by the PR. In post-merge, we always publish deployments though: as it really indicates the componet is used (and we keep updating the same deployment so not much noise). Issue: ZENKO-5132 --- .../create-component-deployments/action.yaml | 24 ++++- .github/workflows/end2end.yaml | 4 + .../create-component-deployments.spec.ts | 93 ++++++++++++++++++- .../test-create-component-deployments.yaml | 9 +- tests/workflows/test-deps-base.yaml | 19 ++++ 5 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 tests/workflows/test-deps-base.yaml diff --git a/.github/actions/create-component-deployments/action.yaml b/.github/actions/create-component-deployments/action.yaml index 1ec2f48ba3..d07a56ea1d 100644 --- a/.github/actions/create-component-deployments/action.yaml +++ b/.github/actions/create-component-deployments/action.yaml @@ -17,6 +17,12 @@ inputs: description: Path to deps.yaml required: false default: solution/deps.yaml + target-branch: + description: >- + Target branch to diff deps against (when set, deployments are created only for + changed componentsets) + required: false + default: '' environment: description: Deployment environment name (e.g. zenko/development/2.11) required: true @@ -42,6 +48,22 @@ inputs: runs: using: composite steps: + - name: Filter to changed dependencies + if: inputs.target-branch != '' + id: filter + shell: bash + run: | + git fetch origin "${{ inputs.target-branch }}" --depth=1 + git show "origin/${{ inputs.target-branch }}:${{ inputs.deps-file }}" > /tmp/base-deps.yaml 2>/dev/null || echo '{}' > /tmp/base-deps.yaml + + yq eval-all ' + select(fi == 0) as $curr | select(fi == 1) as $base | + $curr | with_entries(select(.value.tag != ($base[.key].tag // ""))) + ' '${{ inputs.deps-file }}' /tmp/base-deps.yaml > /tmp/changed-deps.yaml + + cat /tmp/changed-deps.yaml + echo "deps-file=/tmp/changed-deps.yaml" >> "$GITHUB_OUTPUT" + - name: Parse component repos from deps.yaml id: parse uses: actions/github-script@v7 @@ -50,7 +72,7 @@ runs: script: | const { parseDeps } = require('${{ github.action_path }}/parse-deps.js'); const selfRepo = process.env.GITHUB_REPOSITORY || 'scality/zenko'; - const { components, repos } = parseDeps('${{ inputs.deps-file }}', selfRepo); + const { components, repos } = parseDeps('${{ steps.filter.outputs.deps-file || inputs.deps-file }}', selfRepo); if (components.length === 0) { core.info('No component repos found in deps.yaml'); diff --git a/.github/workflows/end2end.yaml b/.github/workflows/end2end.yaml index ec6d1f714f..209202397a 100644 --- a/.github/workflows/end2end.yaml +++ b/.github/workflows/end2end.yaml @@ -681,6 +681,7 @@ jobs: runs-on: ubuntu-24.04 outputs: environment: ${{ steps.env.outputs.environment }} + target-branch: ${{ steps.env.outputs.target_branch }} steps: - name: Checkout uses: actions/checkout@v6 @@ -694,6 +695,7 @@ jobs: --json baseRefName --jq '.[0].baseRefName // empty') if [[ -n "$base_ref" && "$base_ref" == development/* ]]; then echo "environment=zenko/$base_ref" >> "$GITHUB_OUTPUT" + echo "target_branch=$base_ref" >> "$GITHUB_OUTPUT" echo "Target environment: zenko/$base_ref" else echo "No open PR targeting a development branch found, skipping deployments" @@ -704,6 +706,7 @@ jobs: with: app-id: ${{ vars.ACTIONS_APP_ID }} app-private-key: ${{ secrets.ACTIONS_APP_PRIVATE_KEY }} + target-branch: ${{ steps.env.outputs.target_branch }} environment: ${{ steps.env.outputs.environment }} status: in_progress transient: 'true' @@ -747,6 +750,7 @@ jobs: with: app-id: ${{ vars.ACTIONS_APP_ID }} app-private-key: ${{ secrets.ACTIONS_APP_PRIVATE_KEY }} + target-branch: ${{ needs.create-deployments.outputs.target-branch }} environment: ${{ needs.create-deployments.outputs.environment }} status: ${{ steps.result.outputs.status }} transient: 'true' diff --git a/tests/workflows/create-component-deployments.spec.ts b/tests/workflows/create-component-deployments.spec.ts index 1ccc8e37e7..45b1d7702c 100644 --- a/tests/workflows/create-component-deployments.spec.ts +++ b/tests/workflows/create-component-deployments.spec.ts @@ -57,17 +57,19 @@ const commonDeploymentParams = { }; beforeEach(async () => { + // Base deps on main/development/2.11: sorbet v1.2.1, backbeat 9.3.0 + // Feature branch bumps sorbet to v1.2.2 (only changed component) github = new MockGithub({ repo: { zenko: { - currentBranch: "improvement/ZENKO-5210", + pushedBranches: ["development/2.11"], files: [ { src: path.resolve(__dirname, "../..", ".github"), dest: ".github", }, { - src: path.resolve(__dirname, "test-deps.yaml"), + src: path.resolve(__dirname, "test-deps-base.yaml"), dest: "solution/deps.yaml", }, { @@ -80,6 +82,14 @@ beforeEach(async () => { }); await github.setup(); + // Create feature branch with updated deps (sorbet v1.2.1 -> v1.2.2) + const repoPath = github.repo.getPath("zenko"); + const depsUpdate = path.resolve(__dirname, "test-deps.yaml"); + await exec(`git -C ${repoPath} checkout -b improvement/ZENKO-5210`); + await exec(`cp ${depsUpdate} ${repoPath}/solution/deps.yaml`); + await exec(`git -C ${repoPath} add solution/deps.yaml && git -C ${repoPath} commit -m "bump sorbet"`); + await exec(`git -C ${repoPath} push origin improvement/ZENKO-5210`); + moctokit = new Moctokit("http://api.github.com"); act = new Act(github.repo.getPath("zenko")); @@ -171,4 +181,83 @@ describe("create-component-deployments action", () => { expect(deployStep).toBeDefined(); expect(deployStep?.status).toBe(0); }); + + it("should only deploy changed components when target-branch is set", async () => { + nock("http://api.github.com").delete("/installation/token").reply(204).persist(); + act.setInput("target-branch", "development/2.11"); + + const result = await act.runEvent("workflow_dispatch", { + logFile: process.env.ACT_LOG ? path.join(__dirname, "act-delta.log") : undefined, + mockApi: [ + moctokit.rest.apps.getRepoInstallation().reply({ + status: 200, + data: { id: 1, app_id: 12345, app_slug: "test-app" }, + repeat: 5, + }), + moctokit.rest.apps.createInstallationAccessToken().reply({ + status: 201, + data: { token: "ghs_fake_token" }, + repeat: 5, + }), + + // Only sorbet should be deployed (tag changed v1.2.1 -> v1.2.2) + moctokit.rest.repos.createDeployment({ + owner: "scality", + repo: "sorbet", + ref: "v1.2.2", + ...commonDeploymentParams, + }).reply({ status: 201, data: { id: 101 } }), + + moctokit.rest.repos.createDeploymentStatus({ + owner: "scality", + repo: "sorbet", + deployment_id: 101, + state: "in_progress", + log_url: "https://github.com/scality/zenko/actions/runs/1", + description: "Zenko CI running", + }).reply({ status: 201, data: { id: 1 } }), + + // No backbeat mocks — it should NOT be called + ], + bind: true, + }); + + const filterStep = result.find(r => r.name?.includes("Filter to changed dependencies")); + expect(filterStep).toBeDefined(); + expect(filterStep?.status).toBe(0); + + const parseStep = result.find(r => r.name?.includes("Parse component repos")); + expect(parseStep).toBeDefined(); + expect(parseStep?.status).toBe(0); + + const deployStep = result.find(r => r.name?.includes("Create or update deployments")); + expect(deployStep).toBeDefined(); + expect(deployStep?.status).toBe(0); + }); + + it("should skip deployments when no deps changed", async () => { + // Point to the same branch — no diff, so no components + act.setInput("target-branch", "improvement/ZENKO-5210"); + + const result = await act.runEvent("workflow_dispatch", { + logFile: process.env.ACT_LOG ? path.join(__dirname, "act-delta-noop.log") : undefined, + mockApi: [], + bind: true, + }); + + const filterStep = result.find(r => r.name?.includes("Filter to changed dependencies")); + expect(filterStep).toBeDefined(); + expect(filterStep?.status).toBe(0); + + const parseStep = result.find(r => r.name?.includes("Parse component repos")); + expect(parseStep).toBeDefined(); + expect(parseStep?.status).toBe(0); + + // No deployments should be created — token and deploy steps should be skipped + const tokenStep = result.find(r => r.name?.includes("Generate scoped token")); + expect(tokenStep).toBeUndefined(); + + const deployStep = result.find(r => r.name?.includes("Create or update deployments")); + expect(deployStep).toBeUndefined(); + }); }); diff --git a/tests/workflows/test-create-component-deployments.yaml b/tests/workflows/test-create-component-deployments.yaml index 03d1d30827..84bedc2931 100644 --- a/tests/workflows/test-create-component-deployments.yaml +++ b/tests/workflows/test-create-component-deployments.yaml @@ -1,6 +1,12 @@ --- name: Test Create Component Deployments -on: workflow_dispatch +on: + workflow_dispatch: + inputs: + target-branch: + description: Target branch to diff deps against + required: false + default: '' jobs: test-deployments: runs-on: ubuntu-latest @@ -17,6 +23,7 @@ jobs: app-id: 12345 app-private-key: ${{ secrets.APP_PRIVATE_KEY }} deps-file: solution/deps.yaml + target-branch: ${{ inputs.target-branch }} environment: zenko/development/2.11 status: in_progress transient: "true" diff --git a/tests/workflows/test-deps-base.yaml b/tests/workflows/test-deps-base.yaml new file mode 100644 index 0000000000..fe27286710 --- /dev/null +++ b/tests/workflows/test-deps-base.yaml @@ -0,0 +1,19 @@ +sorbet: + sourceRegistry: ghcr.io/scality + image: sorbet + tag: v1.2.1 +backbeat: + sourceRegistry: ghcr.io/scality + image: backbeat + tag: 9.3.0 +kafka: + sourceRegistry: ghcr.io/scality/zenko + image: kafka + tag: 2.13-3.1.2 +playground-sandbox: + sourceRegistry: ghcr.io/scality/playground + image: my-sandbox + tag: feat-branch-abc123 +redis: + image: redis + tag: 7.2.11 From d263ab17f9e0c5707fe12bf281df1cc23f2943a6 Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Wed, 8 Apr 2026 19:02:07 +0200 Subject: [PATCH 4/8] Create deployments for releases Issue: ZENKO-5132 --- .../create-component-deployments/action.yaml | 5 +++++ .../create-deployments.js | 17 ++++++++++------- .github/workflows/release.yaml | 18 ++++++++++++++++++ tests/workflows/create-deployments.spec.ts | 15 +++++++++++++++ tests/workflows/release.spec.ts | 18 ++++++++++++++++++ .../test-create-component-deployments.yaml | 5 +++++ 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/.github/actions/create-component-deployments/action.yaml b/.github/actions/create-component-deployments/action.yaml index d07a56ea1d..14929597c3 100644 --- a/.github/actions/create-component-deployments/action.yaml +++ b/.github/actions/create-component-deployments/action.yaml @@ -33,6 +33,10 @@ inputs: description: Whether deployments are transient (auto-inactivated on newer success) required: false default: 'false' + production: + description: Whether deployments target a production environment + required: false + default: 'false' log-url: description: URL to link from the deployment status required: true @@ -110,6 +114,7 @@ runs: environment: `${{ inputs.environment }}`, status: `${{ inputs.status }}`, transient: ${{ inputs.transient }}, + production: ${{ inputs.production }}, logUrl: `${{ inputs.log-url }}`, description: `${{ inputs.description }}`, token: `${{ steps.app-token.outputs.token }}`, diff --git a/.github/actions/create-component-deployments/create-deployments.js b/.github/actions/create-component-deployments/create-deployments.js index c943313562..1ac215920c 100644 --- a/.github/actions/create-component-deployments/create-deployments.js +++ b/.github/actions/create-component-deployments/create-deployments.js @@ -4,7 +4,7 @@ * @typedef {import('@octokit/rest').Octokit} Octokit * @typedef {{ info: (msg: string) => void, warning: (msg: string) => void, startGroup: (name: string) => void, endGroup: () => void }} Core * @typedef {{ repo: string, ref: string, image: string }} Component - * @typedef {{ token: string, environment: string, description: string, transient: boolean, createOnly: boolean }} DeploymentParams + * @typedef {{ token: string, environment: string, description: string, transient: boolean, production: boolean, createOnly: boolean }} DeploymentParams */ const GHCR_REGISTRY = 'https://ghcr.io'; @@ -88,10 +88,11 @@ async function resolveFromManifest(image, tag, token) { * @param {string} params.environment * @param {string} params.description * @param {boolean} params.transient + * @param {boolean} params.production * @param {boolean} params.createOnly - Skip lookup, always create * @returns {Promise} deployment id */ -async function findOrCreateDeployment(github, { owner, repo, ref, environment, description, transient, createOnly }) { +async function findOrCreateDeployment(github, { owner, repo, ref, environment, description, transient, production, createOnly }) { if (!createOnly) { const { data: existing } = await github.rest.repos.listDeployments({ owner, repo, environment, ref, per_page: 1, @@ -108,7 +109,7 @@ async function findOrCreateDeployment(github, { owner, repo, ref, environment, d auto_merge: false, required_contexts: [], transient_environment: transient, - production_environment: false, + production_environment: production, }); return data.id; @@ -127,7 +128,8 @@ async function findOrCreateDeployment(github, { owner, repo, ref, environment, d * @returns {Promise<{component: Component, deploymentId: number}>} */ async function resolveDeployment(github, core, resolve, { repo, ref, image }, deployParams) { - const { token, environment, description, transient, createOnly } = deployParams; + const { token, environment, description, transient, production, createOnly } = deployParams; + const canRetry = !!repo; // Resolve repo/ref from manifest if not provided if (!repo) { @@ -143,7 +145,7 @@ async function resolveDeployment(github, core, resolve, { repo, ref, image }, de const [owner, repoName] = repo.split('/'); try { const deploymentId = await findOrCreateDeployment(github, { - owner, repo: repoName, ref, environment, description, transient, createOnly, + owner, repo: repoName, ref, environment, description, transient, production, createOnly, }); return { component: { repo, ref, image }, deploymentId }; @@ -167,12 +169,13 @@ async function resolveDeployment(github, core, resolve, { repo, ref, image }, de * @param {string} params.environment - Deployment environment name * @param {string} params.status - Deployment status (in_progress, success, failure) * @param {boolean} params.transient - Whether deployments are transient + * @param {boolean} params.production - Whether deployments target a production environment * @param {string} params.logUrl - URL to link from the deployment status * @param {string} params.description - Human-readable description * @param {string} params.token - GitHub token with packages:read for manifest lookups */ -async function createDeployments({ github, core, components, environment, status, transient, logUrl, description, token }) { - const deployParams = { token, environment, description, transient, createOnly: status === 'in_progress' }; +async function createDeployments({ github, core, components, environment, status, transient, production, logUrl, description, token }) { + const deployParams = { token, environment, description, transient, production, createOnly: status === 'in_progress' }; let errors = 0; for (const component of components) { diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 30245fa9e4..ef39daf98a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -132,10 +132,28 @@ jobs: target_commitish: ${{ github.sha }} prerelease: ${{ env.VERSION_PRERELEASE != '' }} + create-deployments: + runs-on: ubuntu-24.04 + needs: release + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Create release deployments + uses: ./.github/actions/create-component-deployments + with: + app-id: ${{ vars.ACTIONS_APP_ID }} + app-private-key: ${{ secrets.ACTIONS_APP_PRIVATE_KEY }} + environment: "zenko/${{ github.event.inputs.tag }}" + status: success + production: 'true' + log-url: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.event.inputs.tag }} + description: "Released in zenko ${{ github.event.inputs.tag }}" + promote: runs-on: ubuntu-24.04 needs: - verify-release + - create-deployments - release steps: - name: Checkout diff --git a/tests/workflows/create-deployments.spec.ts b/tests/workflows/create-deployments.spec.ts index 6ca120a930..881c24c470 100644 --- a/tests/workflows/create-deployments.spec.ts +++ b/tests/workflows/create-deployments.spec.ts @@ -30,11 +30,13 @@ const deploymentParams = { environment: 'zenko/development/2.11', description: 'Zenko CI running', transient: true, + production: false, }; const baseParams = { environment: 'zenko/development/2.11', transient: true, + production: false, logUrl: 'https://github.com/scality/zenko/actions/runs/123', description: 'Zenko CI running', token: 'fake-token', @@ -46,6 +48,7 @@ describe('resolveDeployment', () => { environment: 'zenko/dev', description: 'test', transient: true, + production: false, createOnly: true, }; const component = { repo: 'scality/sorbet', ref: 'v1.0.0', image: 'scality/sorbet' }; @@ -189,6 +192,18 @@ describe('resolveDeployment', () => { const call = github.rest.repos.createDeployment.firstCall.args[0]; expect(call.transient_environment).toBe(false); }); + + it('passes production_environment: true for production deployments', async () => { + const github = makeMockGithub(); + + await findOrCreateDeployment(github, { + ...deploymentParams, transient: false, production: true, createOnly: true, + }); + + const call = github.rest.repos.createDeployment.firstCall.args[0]; + expect(call.production_environment).toBe(true); + expect(call.transient_environment).toBe(false); + }); }); describe('createDeployments', () => { diff --git a/tests/workflows/release.spec.ts b/tests/workflows/release.spec.ts index 381c4c7b28..40b72abc1a 100644 --- a/tests/workflows/release.spec.ts +++ b/tests/workflows/release.spec.ts @@ -80,6 +80,10 @@ beforeEach(async () => { src: path.resolve(__dirname, "VERSION-2.3.7-rc.1"), dest: "VERSION", }, + { + src: path.resolve(__dirname, "test-deps-base.yaml"), + dest: "solution/deps.yaml", + }, ], }, }, @@ -297,6 +301,20 @@ test.each([ } } }], + 'create-deployments': [{ + name: 'Create release deployments', + mockWith: { + uses: 'actions/github-script@v7', + with: { + 'github-token': "my-token", + script: [ + "const assert = require('assert');", + `assert.strictEqual(core.getInput('environment'), 'zenko/${tag}');`, + "assert.strictEqual(core.getInput('status'), 'success');", + ].join('\n'), + }, + } + }], 'promote': [{ // Need to explicitely pass token, the GITHUB_TOKEN does not seem to be set uses: 'scality/action-artifacts@v4', diff --git a/tests/workflows/test-create-component-deployments.yaml b/tests/workflows/test-create-component-deployments.yaml index 84bedc2931..b04b513c18 100644 --- a/tests/workflows/test-create-component-deployments.yaml +++ b/tests/workflows/test-create-component-deployments.yaml @@ -7,6 +7,10 @@ on: description: Target branch to diff deps against required: false default: '' + production: + description: Whether deployments target a production environment + required: false + default: 'false' jobs: test-deployments: runs-on: ubuntu-latest @@ -27,5 +31,6 @@ jobs: environment: zenko/development/2.11 status: in_progress transient: "true" + production: ${{ inputs.production }} log-url: https://github.com/scality/zenko/actions/runs/1 description: Zenko CI running From 0f47aed8bc3b9e9e377f76584d3fdb9c7ecfcf4c Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Tue, 14 Apr 2026 11:33:20 +0200 Subject: [PATCH 5/8] Properly mock deployment job in release Issue: ZENKO-5132 --- tests/workflows/release.spec.ts | 93 ++++++++++++++++++++++++++--- tests/workflows/test-deps-base.yaml | 4 -- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/tests/workflows/release.spec.ts b/tests/workflows/release.spec.ts index 40b72abc1a..d749aedf9c 100644 --- a/tests/workflows/release.spec.ts +++ b/tests/workflows/release.spec.ts @@ -4,9 +4,40 @@ import path from "path"; import { exec as execCb } from "node:child_process"; import { promisify } from "node:util"; import assert from "assert"; +import nock from "nock"; const exec = promisify(execCb); +// Test-only RSA key (not used anywhere else, generated for act.js mock) +const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDSSu4ghRVAKyjX +c25FKdE+sARk3Jai8k8DCjJU/DMNskgNvGh7JPLDww98Ts9E3ddMNt06oBt5p8qc +YfaInUR0poPcJG6JbPWVEefNC00dTiHlXEGU8Ih8Dc2Ezf/zb45my9DmmGXeVScf +LNYtSSqxfqdjFIOvr8XJ8kuB7L3jyFTBN9xjfdTnmsJ0ilSatV50o6RdVjiZvf2r +z8uhlIw/b0xw8ZZ5rRXNr1VgDW7kcK952OYIDo9qvGMxOASjx9cpUJBkE/nrWF8I +HAGACAqUZJaF4p/CQFjd/7cmUpA+Shh31UdD/rSXzC1bTEB5vaJN8LyVEBYw+n19 +iv6QO/K1AgMBAAECggEAaRNksd4dlK8cHK+CQU/YTGzx/R3VrPzLKxcsuBc+QVE8 +PJTQVfvLy7JLKg9M9LmuStg9KX53zA1hqUsvvupqGqlbSKPxkXxep4pHW0aS1RpF +yI+U+2FGqUnST9II2q/6pPWhX591gybkQekK6ZzeFstUwyaseBwphbMqNHTBGy+F +9A52Zg42QBQbQoIsOiqpJTKxhDpdEx+AZqrG1EawQkygVeNyRnOaJgCgVTIxqk+m +lENCchKeZmo6aul/4LwH9GPDF+1ftMIlAwFXwQgp/IuzFmNf564NSIEZbWgYC8aN +gl3ZU5K6mrwbKpGg2HXPDb6AA0U4WN1Bmw8wLyWhjwKBgQDsgNSi4+kfjLJM9oHQ +PkNTfZ7rmrSdPf8gAodGsrABKz9IhfoCQJ0jNtegaGq3JB+YVJUUWtw4cnyBGOcE +f1F9JIRMcq885p9zwX0jDLLe1vR8305tmYJFweqAViwWQ2riy2IqupsWEhgKev1S +8QgSsaDcP1wksDWokykui85TzwKBgQDjoPOc1zs1teMG5zpSQVm3QT2C3ryRSBRo ++1jyszl8Rrm7x9IcmiyBL5XQOpHoNHH24wUDte+t08V/34mhEwaa3tl1MUmJLsBG ++LkTVXdRcFnHoqCbqFqYeV8eXQgY+Narq245VGGa1CfkhHvqQvuKVlIDLgPPrutg +czA0MpW+OwKBgQDU4ImFLTwzR8Nd/yyNst2LEzGuxIv6VUmFGIGHI2PFSZYmw2Fs +EZjfj4e7PQGBY6SEyu19auN6c6KZ2T5oD+nbiLkEzt3pJXU1Dl6C4/VFG5rpo17G +zDw0af2YEviP+ZMGHSd5aooZ7aNyG45Vz9sCaJxwYx+fbnR+DigtW24WhQKBgQCf +sPXXTWO7jYvk9ukCddhT6NAXdN2Darbu446GTdgBaLi6lTfBWyPnyZNnjv93kPt2 +wdNtxACOyWfgCtnKB8f1dGvIfLhjJko8QBfPCYF4v8IsfNoB+bz9BQEHEyswIbqw +msbsL1d+QGJwPcWVFkLTzTUiB/EijUuR0Z26sNY+qwKBgQDWDEEBjZ+63kttpp4c +EAyXAIwDT+KhVppmXvIAjVkqP6+I8yqUSCFjXMTT1Bubovqk6wAnpYdA559LgRjc +gfkN+TRRaIeVB9jxzFHszenX6CVswwBtwSj331N/87GnI7fF7/ZDmMKRSiRjIQyL +6c4hJmUD3bnLspBgcbLD33c7Dw== +-----END PRIVATE KEY-----`; + let github: MockGithub; let mockapi: Mockapi; let moctokit: Moctokit @@ -130,6 +161,10 @@ beforeEach(async () => { // Set secrets act.setSecret('ARTIFACTS_USER', 'foo'); act.setSecret('ARTIFACTS_PASSWORD', 'bar'); + act.setSecret('ACTIONS_APP_PRIVATE_KEY', TEST_PRIVATE_KEY); + + // Set variables (repository variables) + act.setVar('ACTIONS_APP_ID', '123456'); // For some reason, the GITHUB_REF is not set to the current branch where `act` is executed: so // we need to explicitely set it to the current branch @@ -144,6 +179,7 @@ beforeEach(async () => { afterEach(async () => { await github.teardown(); + nock.cleanAll(); }); const Pass = { toString: () => "pass", value: () => 0 }; @@ -172,6 +208,10 @@ test.each([ act.setInput("tag", tag); + // Post-step: create-github-app-token revokes token via DELETE /installation/token, + // which cannot be matched by moctokit.rest.apps.revokeInstallationAccessToken() + nock('http://api.github.com').delete('/installation/token').reply(204).persist(); + const result = await act.runEvent("workflow_dispatch", { logFile: process.env.ACT_LOG ? "act-release-" + expect.getState().currentTestName!.replace(/[ /]/g, '_') + ".log" @@ -213,7 +253,9 @@ test.each([ .listReleases() // First call from release notes generation, to get the previous release .reply({ status: 200, data: [{ tag_name: '2.3.6', id: 122 }] }) - // Second call made by action-gh-release@v2.5.2+ (after release creation) to handle + // Second call from release notes generation, to get the previous release + .reply({ status: 200, data: [{ tag_name: '2.3.6', id: 122 }] }) + // Third call made by action-gh-release@v2.5.2+ (after release creation) to handle // race condition when release is created by multiple jobs in parallel... .reply({ status: 200, data: [{ tag_name: '2.3.6', id: 122 }, { @@ -270,6 +312,43 @@ test.each([ upload_url: 'http://uploads.github.com/repos/scality/Zenko/releases/456/assets{?name,label}', html_url: 'http://github.com/repos/scality/Zenko/releases/456', }}), + + // Mock create-github-app-token + moctokit.rest.apps + .listInstallations() + .reply({ status: 200, data: [] }), + moctokit.rest.apps + .getRepoInstallation({ owner: 'scality', repo: 'sorbet' }) + .reply({ status: 200, data: { id: 4242, app_slug: 'scality-test-app' } }), + moctokit.rest.apps + .createInstallationAccessToken({ installation_id: 4242 }) + .reply({ status: 201, data: { token: 'my-app-token' } }), + + // Mock deployment creation for sorbet + moctokit.rest.repos + .listDeployments({ + owner: 'scality', repo: 'sorbet', environment: `zenko/${tag}`, ref: 'v1.2.1', per_page: 1, + } as any) + .reply({ status: 200, data: [] }), + moctokit.rest.repos + .createDeployment({ owner: 'scality', repo: 'sorbet' }) + .reply({ status: 201, data: { id: 1001 } }), + moctokit.rest.repos + .createDeploymentStatus({ owner: 'scality', repo: 'sorbet', deployment_id: 1001 }) + .reply({ status: 201, data: {} }), + + // Mock deployment creation for backbeat + moctokit.rest.repos + .listDeployments({ + owner: 'scality', repo: 'backbeat', environment: `zenko/${tag}`, ref: '9.3.0', per_page: 1, + } as any) + .reply({ status: 200, data: [] }), + moctokit.rest.repos + .createDeployment({ owner: 'scality', repo: 'backbeat' }) + .reply({ status: 201, data: { id: 1002 } }), + moctokit.rest.repos + .createDeploymentStatus({ owner: 'scality', repo: 'backbeat', deployment_id: 1002 }) + .reply({ status: 201, data: {} }), ], mockSteps: { 'verify-release': [{ @@ -302,16 +381,16 @@ test.each([ } }], 'create-deployments': [{ + before: 'Checkout', + mockWith: { + name: 'Install js-yaml globally', + run: 'npm install -g js-yaml ; echo "NODE_PATH=$(npm root -g)" >> "$GITHUB_ENV"' + }, + }, { name: 'Create release deployments', mockWith: { - uses: 'actions/github-script@v7', with: { 'github-token': "my-token", - script: [ - "const assert = require('assert');", - `assert.strictEqual(core.getInput('environment'), 'zenko/${tag}');`, - "assert.strictEqual(core.getInput('status'), 'success');", - ].join('\n'), }, } }], diff --git a/tests/workflows/test-deps-base.yaml b/tests/workflows/test-deps-base.yaml index fe27286710..4e65ab70ce 100644 --- a/tests/workflows/test-deps-base.yaml +++ b/tests/workflows/test-deps-base.yaml @@ -10,10 +10,6 @@ kafka: sourceRegistry: ghcr.io/scality/zenko image: kafka tag: 2.13-3.1.2 -playground-sandbox: - sourceRegistry: ghcr.io/scality/playground - image: my-sandbox - tag: feat-branch-abc123 redis: image: redis tag: 7.2.11 From 8dc8625c344276aa55c211b8d9e092dfb4ba8e0e Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Thu, 16 Apr 2026 17:15:38 +0200 Subject: [PATCH 6/8] Drop js-yaml It is not available in github runners, so use yq to convert to JSON instead. Issue: ZENKO-5132 --- .../create-component-deployments/action.yaml | 9 ++++++++- .../create-component-deployments/parse-deps.js | 5 ++--- tests/workflows/parse-deps.spec.ts | 13 +++++++++++-- tests/workflows/release.spec.ts | 6 ------ .../test-create-component-deployments.yaml | 5 ----- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/actions/create-component-deployments/action.yaml b/.github/actions/create-component-deployments/action.yaml index 14929597c3..f76d355793 100644 --- a/.github/actions/create-component-deployments/action.yaml +++ b/.github/actions/create-component-deployments/action.yaml @@ -68,6 +68,13 @@ runs: cat /tmp/changed-deps.yaml echo "deps-file=/tmp/changed-deps.yaml" >> "$GITHUB_OUTPUT" + - name: Convert deps.yaml to JSON + id: json + shell: bash + run: | + yq -o=json '${{ steps.filter.outputs.deps-file || inputs.deps-file }}' > /tmp/changed-deps.json + echo "deps-file=/tmp/changed-deps.json" >> "$GITHUB_OUTPUT" + - name: Parse component repos from deps.yaml id: parse uses: actions/github-script@v7 @@ -76,7 +83,7 @@ runs: script: | const { parseDeps } = require('${{ github.action_path }}/parse-deps.js'); const selfRepo = process.env.GITHUB_REPOSITORY || 'scality/zenko'; - const { components, repos } = parseDeps('${{ steps.filter.outputs.deps-file || inputs.deps-file }}', selfRepo); + const { components, repos } = parseDeps('${{ steps.json.outputs.deps-file }}', selfRepo); if (components.length === 0) { core.info('No component repos found in deps.yaml'); diff --git a/.github/actions/create-component-deployments/parse-deps.js b/.github/actions/create-component-deployments/parse-deps.js index ef1e16ff28..efa4ea86f3 100644 --- a/.github/actions/create-component-deployments/parse-deps.js +++ b/.github/actions/create-component-deployments/parse-deps.js @@ -1,6 +1,5 @@ // @ts-check const fs = require('fs'); -const yaml = require('js-yaml'); /** * Strip @sha256:... digest suffix from a tag. @@ -14,12 +13,12 @@ function stripDigest(tag) { /** * Parse deps.yaml and extract component info for ghcr.io/scality/* images. * - * @param {string} depsFile - Path to deps.yaml + * @param {string} depsFile - Path to deps JSON file (converted from deps.yaml) * @param {string} selfRepo - The current repo (org/name) to exclude from results * @returns {{ components: Array<{repo: string, ref: string, image: string}>, repos: string[] }} */ function parseDeps(depsFile, selfRepo) { - const deps = yaml.load(fs.readFileSync(depsFile, 'utf8')); + const deps = JSON.parse(fs.readFileSync(depsFile, 'utf8')); const seen = new Set(); const components = []; const normalizedSelfRepo = (selfRepo || '').toLowerCase(); diff --git a/tests/workflows/parse-deps.spec.ts b/tests/workflows/parse-deps.spec.ts index 8476106f46..dcc6e18e5e 100644 --- a/tests/workflows/parse-deps.spec.ts +++ b/tests/workflows/parse-deps.spec.ts @@ -1,8 +1,17 @@ import path from 'path'; +import fs from 'fs'; +import yaml from 'js-yaml'; const { parseDeps, stripDigest } = require('../../.github/actions/create-component-deployments/parse-deps'); -const depsFile = path.join(__dirname, '../../solution/deps.yaml'); +/** Convert a YAML file to a temporary JSON file (mirrors the yq step in action.yaml). */ +function yamlToJson(yamlPath: string): string { + const jsonPath = fs.mkdtempSync(path.join(require('os').tmpdir(), 'deps-')) + '/deps.json'; + fs.writeFileSync(jsonPath, JSON.stringify(yaml.load(fs.readFileSync(yamlPath, 'utf8')))); + return jsonPath; +} + +const depsFile = yamlToJson(path.join(__dirname, '../../solution/deps.yaml')); describe('parseDeps', () => { it('extracts scality components from deps.yaml', () => { @@ -71,7 +80,7 @@ describe('parseDeps', () => { }); it('sets empty repo for playground images', () => { - const testDeps = path.join(__dirname, 'test-deps.yaml'); + const testDeps = yamlToJson(path.join(__dirname, 'test-deps.yaml')); const { components } = parseDeps(testDeps, 'scality/zenko'); const playground = components.find((c: { image: string }) => c.image.includes('playground')); diff --git a/tests/workflows/release.spec.ts b/tests/workflows/release.spec.ts index d749aedf9c..559be1638a 100644 --- a/tests/workflows/release.spec.ts +++ b/tests/workflows/release.spec.ts @@ -381,12 +381,6 @@ test.each([ } }], 'create-deployments': [{ - before: 'Checkout', - mockWith: { - name: 'Install js-yaml globally', - run: 'npm install -g js-yaml ; echo "NODE_PATH=$(npm root -g)" >> "$GITHUB_ENV"' - }, - }, { name: 'Create release deployments', mockWith: { with: { diff --git a/tests/workflows/test-create-component-deployments.yaml b/tests/workflows/test-create-component-deployments.yaml index b04b513c18..515adb7742 100644 --- a/tests/workflows/test-create-component-deployments.yaml +++ b/tests/workflows/test-create-component-deployments.yaml @@ -15,11 +15,6 @@ jobs: test-deployments: runs-on: ubuntu-latest steps: - - name: Install js-yaml globally - run: | - npm install -g js-yaml - echo "NODE_PATH=$(npm root -g)" >> "$GITHUB_ENV" - - name: Create component deployments uses: ./.github/actions/create-component-deployments with: From 109237855a661582caaf102872e56806e6876288 Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Tue, 21 Apr 2026 23:19:06 +0200 Subject: [PATCH 7/8] Fix dev deployments Name of deployment is now @, to know precisely what the transient build is, but also where it would land. Issue: ZENKO-5132 --- .github/scripts/resolve-base-branch.sh | 26 +++++ .github/workflows/end2end.yaml | 17 +-- tests/workflows/resolve-base-branch.spec.ts | 118 ++++++++++++++++++++ 3 files changed, 154 insertions(+), 7 deletions(-) create mode 100755 .github/scripts/resolve-base-branch.sh create mode 100644 tests/workflows/resolve-base-branch.spec.ts diff --git a/.github/scripts/resolve-base-branch.sh b/.github/scripts/resolve-base-branch.sh new file mode 100755 index 0000000000..2d7afe676d --- /dev/null +++ b/.github/scripts/resolve-base-branch.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Print the closest origin/development/* branch that HEAD descends from via +# --first-parent (e.g. "development/2.14"), or nothing if none is found. +set -eu + +dev_refs=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/development/*') +[[ -z "${dev_refs}" ]] && exit 0 + +# Walk HEAD's first-parent backward, excluding anything reachable from a dev +# branch. The --boundary commit (prefixed '-') is where HEAD first meets a dev +# branch's reachable set — the fork point. +fork=$(git rev-list --first-parent --boundary HEAD --not ${dev_refs} \ + | sed -n 's/^-//p;/^-/q' | head -1) + +# Empty output means HEAD is itself reachable from a dev branch (no feature +# commits ahead), so HEAD itself is the fork point. +fork=${fork:-HEAD} + +# name-rev's BFS penalises second-parent hops, so for waterfalled commits the +# native origin dev (reached via first-parent) beats downstream ones (reached +# via the merge's second parent). +name=$(git name-rev --refs='refs/remotes/origin/development/*' --name-only "${fork}") +[[ "${name}" == "undefined" ]] && exit 0 + +name=${name%%[~^]*} +echo "${name##*origin/}" diff --git a/.github/workflows/end2end.yaml b/.github/workflows/end2end.yaml index 209202397a..83e4343d29 100644 --- a/.github/workflows/end2end.yaml +++ b/.github/workflows/end2end.yaml @@ -685,20 +685,23 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + filter: blob:none - name: Determine target environment id: env env: - GH_TOKEN: ${{ github.token }} REF_NAME: ${{ github.ref_name }} run: | - base_ref=$(gh pr list --head "$REF_NAME" --state open \ - --json baseRefName --jq '.[0].baseRefName // empty') - if [[ -n "$base_ref" && "$base_ref" == development/* ]]; then - echo "environment=zenko/$base_ref" >> "$GITHUB_OUTPUT" + base_ref=$(./.github/scripts/resolve-base-branch.sh) + + if [[ -n "$base_ref" ]]; then + environment="zenko/$REF_NAME@${base_ref#development/}" + echo "environment=$environment" >> "$GITHUB_OUTPUT" echo "target_branch=$base_ref" >> "$GITHUB_OUTPUT" - echo "Target environment: zenko/$base_ref" + echo "Target environment: $environment" else - echo "No open PR targeting a development branch found, skipping deployments" + echo "No development branch ancestor found, skipping deployments" fi - name: Create transient deployments if: steps.env.outputs.environment != '' diff --git a/tests/workflows/resolve-base-branch.spec.ts b/tests/workflows/resolve-base-branch.spec.ts new file mode 100644 index 0000000000..ecc5cc3543 --- /dev/null +++ b/tests/workflows/resolve-base-branch.spec.ts @@ -0,0 +1,118 @@ +import { MockGithub } from "@kie/mock-github"; +import path from "path"; +import { exec as execCb } from "node:child_process"; +import { promisify } from "node:util"; + +const exec = promisify(execCb); + +const SCRIPT = path.resolve(__dirname, "../..", ".github/scripts/resolve-base-branch.sh"); + +let github: MockGithub; +let repoPath: string; +const devChain: string[] = []; + +async function git(args: string): Promise { + const { stdout } = await exec(`git -C "${repoPath}" ${args}`); + return stdout.trim(); +} + +async function createDev(name: string) { + const base = devChain[devChain.length - 1]; + await git(base ? `checkout -b ${name} ${base}` : `checkout -b ${name}`); + devChain.push(name); +} + +async function commitOn(branch: string, message: string) { + await git(`checkout ${branch}`); + await git(`commit --allow-empty -m "${message}"`); + + // Waterfall to every newer dev branch in the chain + const idx = devChain.indexOf(branch); + if (idx < 0) { + return; + } + + let prev = branch; + for (let i = idx + 1; i < devChain.length; i++) { + const next = devChain[i]; + await git(`checkout ${next}`); + await git(`merge --no-ff ${prev}`); + prev = next; + } +} + +async function branchFrom(name: string, base: string, message: string) { + await git(`checkout -b ${name} ${base}`); + await git(`commit --allow-empty -m "${message}"`); +} + +async function runScript(): Promise { + const { stdout } = await exec(SCRIPT, { cwd: repoPath }); + return stdout.trim(); +} + +beforeAll(async () => { + github = new MockGithub({ + repo: { zenko: {} }, + }); + + await github.setup(); + repoPath = github.repo.getPath("zenko") as string; + + await createDev("development/2.11"); + await commitOn("development/2.11", "2.11: A1"); + await createDev("development/2.12"); + await createDev("development/2.13"); + + await commitOn("development/2.11", "2.11: A2"); + await commitOn("development/2.12", "2.12: B1"); + await branchFrom("feat-on-outdated-2.12", "development/2.12", "feat-outdated: K1"); + await commitOn("development/2.13", "2.13: C1"); + + await commitOn("development/2.11", "2.11: A3"); + await commitOn("development/2.12", "2.12: B2"); + await commitOn("development/2.13", "2.13: C2"); + + await git("push origin development/2.11 development/2.12 development/2.13"); + + await branchFrom("feat-on-2.13", "development/2.13", "feat-on-2.13: F1"); + await branchFrom("stacked-on-2.13", "feat-on-2.13", "stacked: H1"); + await branchFrom("feat-on-2.12", "development/2.12", "feat-on-2.12: G1"); + await branchFrom("feat-on-2.11", "development/2.11", "feat-on-2.11: I1"); +}); + +afterAll(async () => { + await github.teardown(); +}); + +describe("resolve-base-branch.sh", () => { + it("resolves feature branched off latest dev", async () => { + await git("checkout feat-on-2.13"); + expect(await runScript()).toBe("development/2.13"); + }); + + it("resolves feature branched off middle dev", async () => { + await git("checkout feat-on-2.12"); + expect(await runScript()).toBe("development/2.12"); + }); + + it("resolves feature branched off oldest dev", async () => { + await git("checkout feat-on-2.11"); + expect(await runScript()).toBe("development/2.11"); + }); + + it("resolves stacked branch (feature off feature off dev)", async () => { + await git("checkout stacked-on-2.13"); + expect(await runScript()).toBe("development/2.13"); + }); + + it("resolves a dev branch itself to itself", async () => { + await git("checkout development/2.12"); + expect(await runScript()).toBe("development/2.12"); + }); + + it("resolves feature branched off an outdated dev commit", async () => { + await git("checkout feat-on-outdated-2.12"); + expect(await runScript()).toBe("development/2.12"); + }); +}); From 0dd99d2255dd7f9d3a2511c98bf65ed1b1f5e88d Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Wed, 22 Apr 2026 17:35:33 +0200 Subject: [PATCH 8/8] Ignore changes on target branch When computing diff (for transient PR), consider only the changes introduced by this PR. Issue: ZENKO-5132 --- .../create-component-deployments/action.yaml | 10 +++- .github/workflows/end2end.yaml | 3 + .../create-component-deployments.spec.ts | 56 +++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/.github/actions/create-component-deployments/action.yaml b/.github/actions/create-component-deployments/action.yaml index f76d355793..bb4affc8b4 100644 --- a/.github/actions/create-component-deployments/action.yaml +++ b/.github/actions/create-component-deployments/action.yaml @@ -57,8 +57,14 @@ runs: id: filter shell: bash run: | - git fetch origin "${{ inputs.target-branch }}" --depth=1 - git show "origin/${{ inputs.target-branch }}:${{ inputs.deps-file }}" > /tmp/base-deps.yaml 2>/dev/null || echo '{}' > /tmp/base-deps.yaml + # Diff against the merge-base (common ancestor with target branch) rather + # than the target branch tip, so deps where the PR branch is merely + # behind on target don't show up as "changed". Requires fetch-depth: 0 + # on the caller checkout. + git fetch origin "${{ inputs.target-branch }}" + base_ref=$(git merge-base HEAD "origin/${{ inputs.target-branch }}") + echo "Diffing against merge-base $base_ref" + git show "$base_ref:${{ inputs.deps-file }}" > /tmp/base-deps.yaml 2>/dev/null || echo '{}' > /tmp/base-deps.yaml yq eval-all ' select(fi == 0) as $curr | select(fi == 1) as $base | diff --git a/.github/workflows/end2end.yaml b/.github/workflows/end2end.yaml index 83e4343d29..ed45fe6754 100644 --- a/.github/workflows/end2end.yaml +++ b/.github/workflows/end2end.yaml @@ -730,6 +730,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + filter: blob:none - name: Determine CI result id: result env: diff --git a/tests/workflows/create-component-deployments.spec.ts b/tests/workflows/create-component-deployments.spec.ts index 45b1d7702c..c23d6d38e7 100644 --- a/tests/workflows/create-component-deployments.spec.ts +++ b/tests/workflows/create-component-deployments.spec.ts @@ -235,6 +235,62 @@ describe("create-component-deployments action", () => { expect(deployStep?.status).toBe(0); }); + it("should ignore deps advanced on target branch after the feature branch was created", async () => { + // Simulate a stale feature branch: target branch moves on (backbeat bumped) + // while the feature branch only touches sorbet. Diff should be vs the + // merge-base, not the target tip, so backbeat must not be deployed. + nock("http://api.github.com").delete("/installation/token").reply(204).persist(); + + const repoPath = github.repo.getPath("zenko"); + await exec(`git -C ${repoPath} checkout development/2.11`); + await exec(`yq -i '.backbeat.tag = "9.3.5"' ${repoPath}/solution/deps.yaml`); + await exec(`git -C ${repoPath} commit -m "bump backbeat on target" -- solution/deps.yaml`); + await exec(`git -C ${repoPath} push origin development/2.11`); + await exec(`git -C ${repoPath} checkout improvement/ZENKO-5210`); + + act.setEnv("GITHUB_SHA", await getCommitHash()); + act.setInput("target-branch", "development/2.11"); + + const result = await act.runEvent("workflow_dispatch", { + logFile: process.env.ACT_LOG ? path.join(__dirname, "act-delta-stale.log") : undefined, + mockApi: [ + moctokit.rest.apps.getRepoInstallation().reply({ + status: 200, + data: { id: 1, app_id: 12345, app_slug: "test-app" }, + repeat: 5, + }), + moctokit.rest.apps.createInstallationAccessToken().reply({ + status: 201, + data: { token: "ghs_fake_token" }, + repeat: 5, + }), + + // Only sorbet should be deployed (merge-base diff). Backbeat + // differs vs target tip but was not touched by the feature + // branch, so no mock for it. + moctokit.rest.repos.createDeployment({ + owner: "scality", + repo: "sorbet", + ref: "v1.2.2", + ...commonDeploymentParams, + }).reply({ status: 201, data: { id: 101 } }), + + moctokit.rest.repos.createDeploymentStatus({ + owner: "scality", + repo: "sorbet", + deployment_id: 101, + state: "in_progress", + log_url: "https://github.com/scality/zenko/actions/runs/1", + description: "Zenko CI running", + }).reply({ status: 201, data: { id: 1 } }), + ], + bind: true, + }); + + expect(result.find(r => r.name?.includes("Filter to changed dependencies"))?.status).toBe(0); + expect(result.find(r => r.name?.includes("Create or update deployments"))?.status).toBe(0); + }); + it("should skip deployments when no deps changed", async () => { // Point to the same branch — no diff, so no components act.setInput("target-branch", "improvement/ZENKO-5210");