Select Git revision
parameters.ts
gitlab-ci.ts 13.22 KiB
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)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
nextVersionObject!.patch++
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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`
}
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) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
versionObject1!.major !== versionObject2!.major
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
versionObject2!.major - versionObject1!.major
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
versionObject1!.minor !== versionObject2!.minor
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
versionObject2!.minor - versionObject1!.minor
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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)
})