import assert from "assert" import { $, fetch, fs } from "zx" interface Package { name: string version: string } export interface VersionObject { major: number minor: number patch: number } const { CI_COMMIT_BRANCH, CI_COMMIT_TAG, CI_COMMIT_TITLE, CI_DEFAULT_BRANCH, CI_JOB_TOKEN, CI_PIPELINE_SOURCE, CI_PROJECT_NAME, CI_PROJECT_NAMESPACE, CI_SERVER_HOST, CI_SERVER_URL, // NPM_EMAIL, // NPM_TOKEN, SSH_KNOWN_HOSTS, SSH_PRIVATE_KEY, } = process.env const packagePath = "package.json" function checkVersionObject( { major: majorReference, minor: minorReference, patch: patchReference, }: VersionObject, versionObject: VersionObject | undefined, ): boolean { if (versionObject === undefined) { return true } const { major, minor, patch } = versionObject if (major < majorReference) { return true } if (major === majorReference) { if (minor < minorReference) { return true } if (minor === minorReference) { return patch <= patchReference || patch === patchReference + 1 } return minor === minorReference + 1 && patch === 0 } return major === majorReference + 1 && minor === 0 && patch === 0 } async function commitAndPushWithUpdatedVersions( nextVersionObject?: VersionObject | undefined, ) { if (nextVersionObject === undefined) { // Retrieve next version of project. nextVersionObject = await latestVersionObjectFromTags() assert.notStrictEqual(nextVersionObject, undefined) nextVersionObject!.patch++ } const nextVersion = versionFromObject(nextVersionObject!) if ((await $`git diff --quiet --staged`.exitCode) !== 0) { let packageJson = await fs.readFile(packagePath, "utf-8") packageJson = packageJson.replace( /^ {2}"version": "(.*?)",$/m, ` "version": "${nextVersion}",`, ) await fs.writeFile(packagePath, packageJson, "utf-8") } await $`git add .` if ((await $`git diff --quiet --staged`.exitCode) !== 0) { await $`git commit -m ${nextVersion}` await $`git push --set-upstream origin master` } await $`git tag -a ${nextVersion} -m ${nextVersion}` await $`git push --set-upstream --tags` // // Note: Don't fail when there is nothing new to publish. // if (NPM_EMAIL !== undefined && NPM_TOKEN !== undefined) { // await nothrow($`npm publish`) // } } async function configureGit() { console.log("Configuring git…") // Set the Git user name and email. await $`git config --global user.email "admin+leximpact-socio-fiscal-ui-ci@tax-benefit.org"` await $`git config --global user.name "Leximpact socio-fiscal UI CI"` console.log("Git configuration completed.") } // async function configureNpm() { // console.log("Configuring npm…") // // Configure npm to be able to publish packages. // if (NPM_EMAIL !== undefined && NPM_TOKEN !== undefined) { // await $`echo ${`//registry.npmjs.org/:_authToken=${NPM_TOKEN}`} > ~/.npmrc` // await $`echo ${`email=${NPM_EMAIL}`} >> ~/.npmrc` // } // console.log("Npm configuration completed.") // } async function configureSsh() { console.log("Configuring ssh…") // Note: `eval $(ssh-agent -s)` must be done before calling this script because it // creates 2 environment variables (then used by ssh clients). // // Install ssh-agent if not already installed, to be able to use git with ssh. // if ((await $`which ssh-agent`.exitCode) !== 0) { // await $`apt install -y openssh-client` // } // // Run ssh-agent (inside the build environment) // await $`eval $(ssh-agent -s)` // Add the SSH key stored in SSH_PRIVATE_KEY variable to the agent store // Use `tr` to fix line endings which makes ed25519 keys work without // extra base64 encoding. // https://gitlab.com/gitlab-examples/ssh-private-key/issues/1#note_48526556 if (SSH_PRIVATE_KEY !== undefined) { await $`echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -` } if (SSH_KNOWN_HOSTS !== undefined) { // Create the SSH directory and give it the right permissions await $`mkdir -p ~/.ssh` await $`chmod 700 ~/.ssh` // Accept the SSH host keys of GitLab server. await $`echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts` await $`chmod 644 ~/.ssh/known_hosts` } console.log("Ssh configuration completed.") } async function latestVersionObjectFromTags(): Promise< VersionObject | undefined > { // Retrieve all tags. await $`git fetch --all --tags` return (await $`git tag --list`).stdout .split("\n") .map(objectFromVersion) .filter((versionObject) => versionObject !== undefined) .sort((versionObject1, versionObject2) => versionObject1.major !== versionObject2.major ? versionObject2.major - versionObject1.major : versionObject1.minor !== versionObject2.minor ? versionObject2.minor - versionObject1.minor : versionObject2.patch - versionObject1.patch, )[0] } async function main() { console.log("Starting gitlab-ci.ts…") await configureGit() // await configureNpm() await configureSsh() console.log(`Handling "${CI_PIPELINE_SOURCE}" event…`) switch (CI_PIPELINE_SOURCE) { case "api": case "pipeline": case "schedule": case "trigger": case "web": { if (CI_COMMIT_BRANCH === CI_DEFAULT_BRANCH) { // A pipeline has been triggered (for example when OpenFisca-France has been updated or a user as // launched a new pipeline manually or…). // => A new version of project must be created if and only if a dependency has // changed. console.log(`Handling trigger…`) await resetGitRepository() // Use the latest version of @leximpact/socio-fiscal-openfisca-json await $`npm install @leximpact/socio-fiscal-openfisca-json@latest` // Test project with the current dependencies (and latest version of @leximpact/socio-fiscal-openfisca-json). await $`ln -s example.env .env` await $`npm run build` // await $`npm test` await $`git add .` if ((await $`git diff --quiet --staged`.exitCode) !== 0) { // `npm install @leximpact/socio-fiscal-openfisca-json@latest` has updated `package-lock.json`. // => Generate a new version of leximpact-socio-fiscal-api. const pkg = await fs.readJson(packagePath) const nextVersionObject = await nextVersionObjectFromPackageAndTags(pkg) await commitAndPushWithUpdatedVersions(nextVersionObject) await triggerDevDeployPipeline() await triggerProdDeployPipeline() } } else { console.log( `Unhandled event "${CI_PIPELINE_SOURCE}" in branch "${CI_COMMIT_BRANCH}".`, ) } break } case "push": { if (CI_COMMIT_BRANCH !== undefined) { console.log(`Push to branch ${CI_COMMIT_BRANCH}`) if (CI_COMMIT_BRANCH.match(/^\d+_\d+_\d+$/) != null) { console.log( `Ignoring commit in version branch (for branch ${CI_COMMIT_BRANCH}).`, ) } else if (CI_COMMIT_TITLE?.match(/^\d+\.\d+\.\d+( |$)/) != null) { console.log( `Ignoring version commit (for version ${CI_COMMIT_TITLE}).`, ) } else { await resetGitRepository() // Use the latest version of @leximpact/socio-fiscal-openfisca-json await $`npm install @leximpact/socio-fiscal-openfisca-json@latest` const pkg = await fs.readJson(packagePath) const nextVersionObject = await nextVersionObjectFromPackageAndTags(pkg) // Test project with the current dependencies (and latest @leximpact/socio-fiscal-openfisca-json). await $`ln -s example.env .env` await $`npm run build` // await $`npm test` if (CI_COMMIT_BRANCH === CI_DEFAULT_BRANCH) { // A merge request has been merged into master (ie content of project has been changed). // => Create a new version of project. await commitAndPushWithUpdatedVersions(nextVersionObject) await triggerDevDeployPipeline() await triggerProdDeployPipeline() } } } else if (CI_COMMIT_TAG !== undefined) { console.log(`Pushing commit tag "${CI_COMMIT_TAG}"…`) } else { console.log(`Unhandled push event.`) } break } default: console.log( `Unhandled event "${CI_PIPELINE_SOURCE}" in branch "${CI_COMMIT_BRANCH}".`, ) } } function maxVersionObject( versionObject1: VersionObject, versionObject2: VersionObject | undefined, ): VersionObject { if (versionObject2 === undefined) { return versionObject1 } const { major: major1, minor: minor1, patch: patch1 } = versionObject1 const { major: major2, minor: minor2, patch: patch2 } = versionObject2 if (major1 < major2) { return versionObject2 } if (major1 > major2) { return versionObject1 } if (minor1 < minor2) { return versionObject2 } if (minor1 > minor2) { return versionObject1 } if (patch1 < patch2) { return versionObject2 } if (patch1 > patch2) { return versionObject1 } // Both versions are the same. Return one of them. return versionObject1 } async function nextVersionObjectFromPackageAndTags( pkg: Package, ): Promise<VersionObject> { // Retrieve current version of project. const tagVersionObject = (await latestVersionObjectFromTags()) ?? { major: 0, minor: 0, patch: 0, } const tagVersion = versionFromObject(tagVersionObject) let nextVersionObject = { ...tagVersionObject, patch: tagVersionObject.patch + 1, } // Ensure that the version numbers of project packages are // compatible with last version tag. const { version: packageVersion } = pkg const packageVersionObject = objectFromVersion(packageVersion) assert( checkVersionObject(tagVersionObject, packageVersionObject), `In ${packagePath}, project version should be compatible with ${tagVersion}, got "${packageVersion}" instead.`, ) nextVersionObject = maxVersionObject(nextVersionObject, packageVersionObject) return nextVersionObject } function objectFromVersion( version: string | undefined, ): VersionObject | undefined { if (version === undefined) { return undefined } const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/) as string[] if (match === null) { return undefined } return { major: parseInt(match[1]), minor: parseInt(match[2]), patch: parseInt(match[3]), } } async function resetGitRepository() { // Reset current repo, because it may have been tranformed by a previous CI that failed. await $`git reset --hard` await $`git switch ${CI_COMMIT_BRANCH}` await $`git fetch origin ${CI_COMMIT_BRANCH}` await $`git reset --hard origin/${CI_COMMIT_BRANCH}` await $`git status` if (CI_COMMIT_BRANCH === CI_DEFAULT_BRANCH) { await $`git remote set-url origin git@${CI_SERVER_HOST}:${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}.git` } else { await $`git remote set-url origin https://${CI_SERVER_HOST}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}.git` } } async function triggerDevDeployPipeline() { const urlString = new URL( `/api/v4/projects/28/trigger/pipeline`, CI_SERVER_URL, ).toString() console.log( "Triggering LexImpact socio-fiscal Dev Deploy pipeline on master branch…", ) const response = await fetch(urlString, { body: new URLSearchParams({ ref: "master", token: CI_JOB_TOKEN!, }).toString(), headers: { "Content-Type": "application/x-www-form-urlencoded", }, method: "POST", }) assert( response.ok, `Unexpected response from ${urlString}: ${response.status} ${ response.statusText }\n${await response.text()}`, ) } async function triggerProdDeployPipeline() { const urlString = new URL( `/api/v4/projects/29/trigger/pipeline`, CI_SERVER_URL, ).toString() console.log( "Triggering LexImpact socio-fiscal Prod Deploy pipeline on master branch…", ) const response = await fetch(urlString, { body: new URLSearchParams({ ref: "master", token: CI_JOB_TOKEN!, }).toString(), headers: { "Content-Type": "application/x-www-form-urlencoded", }, method: "POST", }) assert( response.ok, `Unexpected response from ${urlString}: ${response.status} ${ response.statusText }\n${await response.text()}`, ) } function versionFromObject({ major, minor, patch }: VersionObject): string { return `${major}.${minor}.${patch}` } main() .then(() => { process.exit(0) }) .catch((error: unknown) => { console.error(error) process.exit(1) })