diff --git a/.github/actions/create-component-deployments/action.yaml b/.github/actions/create-component-deployments/action.yaml new file mode 100644 index 0000000000..bb4affc8b4 --- /dev/null +++ b/.github/actions/create-component-deployments/action.yaml @@ -0,0 +1,134 @@ +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 + 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: + 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 + 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 + 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' + production: + description: Whether deployments target a production environment + 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: Filter to changed dependencies + if: inputs.target-branch != '' + id: filter + shell: bash + run: | + # 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 | + $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: 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 + 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('${{ steps.json.outputs.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 + permission-packages: read + + - 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 }}, + 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 new file mode 100644 index 0000000000..1ac215920c --- /dev/null +++ b/.github/actions/create-component-deployments/create-deployments.js @@ -0,0 +1,220 @@ +// @ts-check + +/** + * @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, production: 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. + * + * 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.production + * @param {boolean} params.createOnly - Skip lookup, always create + * @returns {Promise} deployment id + */ +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, + }); + 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: production, + }); + + 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, production, createOnly } = deployParams; + const canRetry = !!repo; + + // 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, production, 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} 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 {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, production, logUrl, description, token }) { + const deployParams = { token, environment, description, transient, production, createOnly: status === 'in_progress' }; + let errors = 0; + + for (const component of components) { + core.startGroup(`${component.repo || component.image}:${component.ref}`); + + try { + 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, + deployment_id: deploymentId, + state: status, + log_url: logUrl, + description, + }); + + core.info(`Deployment ${deploymentId}, status: ${status}`); + } catch (/** @type {any} */ err) { + core.warning(`Failed on ${component.repo || component.image}: ${err.message}`); + errors++; + } + + core.endGroup(); + } + + if (errors > 0) { + core.warning(`${errors} deployment(s) failed (non-fatal)`); + } + + return errors; +} + +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 new file mode 100644 index 0000000000..efa4ea86f3 --- /dev/null +++ b/.github/actions/create-component-deployments/parse-deps.js @@ -0,0 +1,64 @@ +// @ts-check +const fs = require('fs'); + +/** + * 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 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 = JSON.parse(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 tag = stripDigest(entry.tag); + const key = `${fullPath} ${tag}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + + 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]).filter(Boolean), + )]; + + return { components, repos }; +} + +module.exports = { parseDeps, stripDigest }; 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 bdd2ea2ded..ed45fe6754 100644 --- a/.github/workflows/end2end.yaml +++ b/.github/workflows/end2end.yaml @@ -677,6 +677,96 @@ jobs: junit-paths: ${{ github.workspace }}/tests/ctst/reports/*.xml if: always() + create-deployments: + 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 + with: + fetch-depth: 0 + filter: blob:none + - name: Determine target environment + id: env + env: + REF_NAME: ${{ github.ref_name }} + run: | + 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: $environment" + else + echo "No development branch ancestor 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 }} + target-branch: ${{ steps.env.outputs.target_branch }} + 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 + with: + fetch-depth: 0 + filter: blob:none + - 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 }} + target-branch: ${{ needs.create-deployments.outputs.target-branch }} + 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/.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/.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..c23d6d38e7 --- /dev/null +++ b/tests/workflows/create-component-deployments.spec.ts @@ -0,0 +1,319 @@ +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 () => { + // 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: { + pushedBranches: ["development/2.11"], + files: [ + { + src: path.resolve(__dirname, "../..", ".github"), + dest: ".github", + }, + { + src: path.resolve(__dirname, "test-deps-base.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(); + + // 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")); + 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 + // playground-sandbox has empty repo, manifest resolution will fail (non-fatal) + + // 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); + }); + + 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 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"); + + 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/create-deployments.spec.ts b/tests/workflows/create-deployments.spec.ts new file mode 100644 index 0000000000..881c24c470 --- /dev/null +++ b/tests/workflows/create-deployments.spec.ts @@ -0,0 +1,414 @@ +import sinon from 'sinon'; + +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(), + }; +} + +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, + 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', +}; + +describe('resolveDeployment', () => { + const deployParams = { + token: 'fake-token', + environment: 'zenko/dev', + description: 'test', + transient: true, + production: false, + 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('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'); + }); + + it('throws when manifest resolution fails to return a repo', async () => { + const github = makeMockGithub(); + const core = makeMockCore(); + const resolve = sinon.stub().resolves(null); + + await expect( + resolveDeployment(github, core, resolve, { repo: '', ref: 'tag', image: 'scality/playground/x' }, deployParams) + ).rejects.toThrow('Could not resolve repo'); + }); + + 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'); + }); + + 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' }); + + const result = await resolveDeployment(github, core, resolve, component, deployParams); + + expect(result.deploymentId).toBe(55); + expect(createDeployment.callCount).toBe(2); + }); + + 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(); + + 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); + }); + + 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', () => { + 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('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); + }); + + 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); + }); +}); + +describe('resolveFromManifest', () => { + let fetchStub: sinon.SinonStub; + + function mockOk(body: object) { + return { ok: true, json: () => Promise.resolve(body) }; + } + function mockFail() { + return { ok: false }; + } + + beforeEach(() => { + fetchStub = sinon.stub(globalThis as any, 'fetch'); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + it('returns null when auth request fails', async () => { + fetchStub.resolves(mockFail()); + + const result = await resolveFromManifest('scality/sorbet', 'v1.0.0', 'gh-token'); + + expect(result).toBeNull(); + expect(fetchStub.callCount).toBe(1); + }); + + it('returns null when manifest request fails', async () => { + fetchStub.onFirstCall().resolves(mockOk({ token: 'reg-token' })); + fetchStub.onSecondCall().resolves(mockFail()); + + const result = await resolveFromManifest('scality/sorbet', 'v1.0.0', 'gh-token'); + + expect(result).toBeNull(); + expect(fetchStub.callCount).toBe(2); + }); + + 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 + }); + + 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/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..dcc6e18e5e --- /dev/null +++ b/tests/workflows/parse-deps.spec.ts @@ -0,0 +1,133 @@ +import path from 'path'; +import fs from 'fs'; +import yaml from 'js-yaml'; + +const { parseDeps, stripDigest } = require('../../.github/actions/create-component-deployments/parse-deps'); + +/** 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', () => { + 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 = yamlToJson(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/release.spec.ts b/tests/workflows/release.spec.ts index 381c4c7b28..559be1638a 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 @@ -80,6 +111,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", + }, ], }, }, @@ -126,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 @@ -140,6 +179,7 @@ beforeEach(async () => { afterEach(async () => { await github.teardown(); + nock.cleanAll(); }); const Pass = { toString: () => "pass", value: () => 0 }; @@ -168,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" @@ -209,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 }, { @@ -266,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': [{ @@ -297,6 +380,14 @@ test.each([ } } }], + 'create-deployments': [{ + name: 'Create release deployments', + mockWith: { + with: { + 'github-token': "my-token", + }, + } + }], '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/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"); + }); +}); diff --git a/tests/workflows/test-create-component-deployments.yaml b/tests/workflows/test-create-component-deployments.yaml new file mode 100644 index 0000000000..515adb7742 --- /dev/null +++ b/tests/workflows/test-create-component-deployments.yaml @@ -0,0 +1,31 @@ +--- +name: Test Create Component Deployments +on: + workflow_dispatch: + inputs: + target-branch: + 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 + steps: + - 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 + target-branch: ${{ inputs.target-branch }} + 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 diff --git a/tests/workflows/test-deps-base.yaml b/tests/workflows/test-deps-base.yaml new file mode 100644 index 0000000000..4e65ab70ce --- /dev/null +++ b/tests/workflows/test-deps-base.yaml @@ -0,0 +1,15 @@ +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 +redis: + image: redis + tag: 7.2.11 diff --git a/tests/workflows/test-deps.yaml b/tests/workflows/test-deps.yaml new file mode 100644 index 0000000000..fdc6b5a245 --- /dev/null +++ b/tests/workflows/test-deps.yaml @@ -0,0 +1,19 @@ +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 +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 */ 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"