import java.util.regex.Matcher /** * Pipeline to generate and to publish the documentation of a new version of Silverpeas. This * pipeline should be triggered once the release of Silverpeas has been successfully performed as it * will fetch the release report for additional required information. * * This pipeline requires the following job 'Silverpeas Project Web Site Publisher' to run the * corresponding pipeline in order to publish the Silverpeas community web site. * * This pipeline requires the following parameters: * SILVERPEAS_VERSION the version of Silverpeas for which the documentation has to be generated * and published. * * The build is performed within a dedicated Docker image in order to ensure the reproducibility of * the builds and to containerize them from the host OS. */ String imageVersion = getDockerImageVersion() def projects = [ core : 'Silverpeas-Core', components : 'Silverpeas-Components'] pipeline { agent { docker { image "silverpeas/silverbuild:${imageVersion}" args ''' -v $HOME/.m2/settings.xml:/home/silverbuild/.m2/settings.xml -v $HOME/.m2/settings-security.xml:/home/silverbuild/.m2/settings-security.xml -v $HOME/.gitconfig:/home/silverbuild/.gitconfig -v $HOME/.ssh:/home/silverbuild/.ssh -v $HOME/.gnupg:/home/silverbuild/.gnupg ''' } } parameters { string ( description: 'the version of Silverpeas for which the documentation will be generated', name: 'SILVERPEAS_VERSION' ) } environment { gitBaseRepo = 'https://github.com/Silverpeas/' gitCredential = 'cacc0467-7c85-41d1-bf4e-eaa470dd5e59' mavenRepo = 'https://nexus3.silverpeas.org/repository/silverpeas' releaseBranch = '' } stages { stage('Check version of Silverpeas') { when { expression { !isStableVersion() } } steps { error("Documentation is published only for a stable version. Version here is: ${params.SILVERPEAS_VERSION}") } } stage('Prepare the publishing') { steps { copyArtifacts projectName: "Silverpeas_Release", flatten: true, selector: specific(params.SILVERPEAS_VERSION) script { def report = readYaml file: 'release.yaml' releaseBranch = report.branch } sh 'rm -f release.yaml' } } stage('Publish documentation of Silverpeas Core') { when { expression { releaseBranch == 'master' } } steps { dir(projects.core) { git credentialsId: gitCredential, url: (gitBaseRepo + projects.core) sh """ git checkout ${params.SILVERPEAS_VERSION} mvn site-deploy -Pdeployment -Djava.awt.headless=true -Dmaven.test.skip=true """ // the documentation of the REST API is produced by the default lifecycle: the resolve // goal of Swagger is bound to the compile phase and forks no compilation, hence the // site lifecycle alone would publish a page without any specification sh ''' mvn package site-deploy -Pdeployment,restapi -pl core-restapi \ -Djava.awt.headless=true -Dmaven.test.skip=true ''' checkRestApiDoc('core-restapi/target/openapi/openapi.json', 150) } } } stage('Publish documentation of Silverpeas Components') { when { expression { releaseBranch == 'master' } } steps { dir(projects.components) { git credentialsId: gitCredential, url: (gitBaseRepo + projects.components) sh """ git checkout ${params.SILVERPEAS_VERSION} mvn site-deploy -Pdeployment -Djava.awt.headless=true -Dmaven.test.skip=true """ sh ''' mvn package site-deploy -Pdeployment,restapi -pl components-restapi \ -Djava.awt.headless=true -Dmaven.test.skip=true ''' checkRestApiDoc('components-restapi/target/openapi/openapi.json', 60) } } } stage('Check the consistency of the REST API documentations') { when { expression { releaseBranch == 'master' } } steps { checkCommonResponses( "${projects.core}/core-restapi/target/openapi/openapi.json", "${projects.components}/components-restapi/target/openapi/openapi.json") } } stage('Publish Silverpeas Community Web Site') { steps { build job: 'Silverpeas Project Web Site Publisher', parameters: [ string(name: 'SILVERPEAS_VERSION', value: params.SILVERPEAS_VERSION), ], wait: true } } } post { success { script { currentBuild.displayName = params.SILVERPEAS_VERSION } } always { step([$class : 'Mailer', notifyEveryUnstableBuild: true, recipients : "david.lesimple@silverpeas.org, sebastien.vuillet@silverpeas.org, aurore.allibe@silverpeas.org, miguel.moquillon@silverpeas.org, silveryocha@chastagnier.com", sendToIndividuals : true]) } } } String getBranch() { Matcher matcher = params.SILVERPEAS_VERSION =~ '^(\\d+.\\d+)\\..*$' matcher ? "${matcher[0][1]}.x" : 'master' } boolean isStableVersion() { Matcher m = params.SILVERPEAS_VERSION =~ '^\\d+.\\d+(.\\d+)?$' return m.matches() } String getDockerImageVersion() { Matcher matcher = params.SILVERPEAS_VERSION =~ '^(\\d+.\\d+)\\..*$' matcher ? "${matcher[0][1]}" : 'latest' } /** * Checks the OpenAPI document that has just been generated actually documents the REST API. The * generation of such a document is silent about its own failures: it reports a success even when * it produces nothing. Without this check, an incomplete documentation would be published unnoticed. *
* Both the paths and the info section are checked. The latter is required by OpenAPI, and a * renderer refuses to display a document that misses it, whatever the quality of the rest. * @param document the path of the generated OpenAPI document. * @param expectedPathCount the minimal count of paths the document is expected to declare. */ void checkRestApiDoc(String document, int expectedPathCount) { sh """ python3 - <<'EOF' import json, sys specification = json.load(open('${document}')) paths = specification.get('paths', {}) info = specification.get('info') or {} operations = sum(len(o) for o in paths.values()) summarized = sum(1 for p in paths.values() for o in p.values() if o.get('summary')) print(f'REST API documentation: {len(paths)} paths, {operations} operations, ' f'{summarized} of them summarized') if not info.get('title') or not info.get('version'): sys.exit('FAILURE: the specification declares no title nor version in its info section, ' 'which OpenAPI requires and without which a renderer displays nothing') if len(paths) < ${expectedPathCount}: sys.exit(f'FAILURE: only {len(paths)} paths documented, less than the expected ${expectedPathCount}') if summarized < operations: sys.exit(f'FAILURE: {operations - summarized} operations without any summary, which betrays ' f'a scan of artefacts built out of the documented branch') EOF """ } /** * Checks the responses that are common to all the endpoints, whatever the web resource they belong * to, are documented the same way in all the specifications. Such a response is brought by the * CommonResponsesFilter class, which core-rs provides to every project documenting its REST API. * Each specification must therefore declare it, and word it the same way: two of them disagreeing * would betray a project documented against another version of that class than the one of the * branch being released. * @param documents the paths of the generated OpenAPI documents to compare. */ void checkCommonResponses(String... documents) { sh """ python3 - ${documents.join(' ')} <<'EOF' import json, sys CODE = '503' METHODS = ('get', 'put', 'post', 'delete', 'patch', 'head', 'options') wordings = {} missing = [] for path in sys.argv[1:]: for uri, item in json.load(open(path)).get('paths', {}).items(): for method, operation in item.items(): if method not in METHODS: continue response = operation.get('responses', {}).get(CODE) if response is None: missing.append(f'{method.upper()} {uri} of {path}') else: wordings.setdefault(response.get('description'), set()).add(path) if missing: sys.exit(f'FAILURE: {len(missing)} operations without any {CODE} response, the first one ' f'being {missing[0]}') if len(wordings) > 1: for wording, documents in wordings.items(): print(f' {sorted(documents)}: {wording}') sys.exit(f'FAILURE: the {CODE} response is worded in {len(wordings)} different ways') print(f'Common {CODE} response, consistent across the specifications: {next(iter(wordings))}') EOF """ }