diff --git a/release-candidate/README.md b/release-candidate/README.md new file mode 100644 index 00000000..d541689b --- /dev/null +++ b/release-candidate/README.md @@ -0,0 +1,147 @@ +# ASF Infrastructure Release Candidate Action + +This is a GitHub Action that can be used to create release candidates. Note +that it is somewhat opinionated on how release candidates are organized. This +is not intended to be used by all projects. + +## Prerequisites + +* Apache Security Team has approved your project for + [Automated Release Signing](https://infra.apache.org/release-signing.html#automated-release-signing) + and INFRA has set secrets for the repository, including a GPG signing key, + SVN username/password, and nexus username/password. +* The `runs-on` workflow setting should be Linux based (e.g. `ubuntu-latest`) +* The repository must be checked out using `actions/checkout` action prior to + triggering this action +* The repository must have a `VERSION` file containing the current version of the + project (e.g. `1.0.0`) +* When triggered from a tag, the tag must follow the pattern `v-*` + (e.g. `v1.0.0-rc1`) +* When triggered from a tag, the tag must be signed and verified by a key + listed in `https://downloads.apache.org//KEYS`. + +## Setup Operations + +Below are the operations this action does to setup the environment for a +release candidate workflow: + +* Checkout the project's `dist/dev/` directory and create a directory for + release artifacts in `https://dist.apache.org/repos/dist/dev///-rcX`. + The `artifact_dir` output is set to this directory. Note that `` + is optional if the artifact directory should be in the root of the + `` +* Delete previous release candidates from `dist/dev/` for the same version + Useful if the first rc fails the VOTE and more are needed +* Create a zip source artifact using git archive. The artifact is written to + `src/apache---src.zip` in the above artifact directory +* Export `SOURCE_DATE_EPOCH` environment variable to match the timestamp of the + current commit +* Configure global SBT [Simple Build Tool](https://scala-sbt.org) settings to + enabling publishing signed jars to the ASF nexus staging repository. Workflow + steps can use `sbt pubilshSigned` without needing any other configuration. If + publishing is disabled, SBT is configured to publish to a local maven repo on + the CI system, so `sbt publishSigned` can still be used without actually + publishing anything. +* TODO: Add configurations for Maven/Gradle/etc to support other build tools or + staging to non-maven repositories + +## Post Operations + +If the workflow job does not succeed, none of the following actions are taken. +Files added to `dist/dev/` will not be committed. If the workflow published +files to the ASF staging nexus repository, those files must be manually +dropped. + +If the workflow job successfully completes, the following actions are performed +at the end of the workflow: + +* Create sha512 checksum files for all artifacts +* Create detached ASCII armored GPG signatures for all artifacts +* Sign all rpm artifacts with the GPG key with rpmsign +* Commit all files added to `dist/dev/` to SVN + +Note that committing to SVN is is disabled if any of the following are true: +* The `publish` action setting is not explicitly set to `true` +* The `VERSION` file contains `-SNAPSHOT` +* The workflow is not triggered from the push of a tag +* The repository is not in the `apache` organization + +If any of the above are true and publishing is disabled, the artifact directory +is uploaded as a GitHub workflow artifact. It will be retained for one day. +This is useful for testing the workflow using workflow dispatch. + +## Inputs +| Input | Required | Default | Description | +|-----------------|----------|---------|-------------| +| tlp_dir | yes | | Directory of the top level project in dist/dev/ | +| project_name | yes | | Human readable name of the project | +| project_id | yes | | ID of the project, used in source artifact file name | +| project_dir | no | "" | Directory for the project in dev/dist//. Omit if at the root | +| gpg_signing_key | yes | | Key used to sign artifacts | +| svn_username | yes | | Username for publishing release artifacts to SVN dev/dist | +| svn_password | yes | | Password for publishing release artifacts to SVN dev/dist | +| nexus_username | yes | | Username for publishing release artifacts to Nexus | +| nexus_password | yes | | Password for publishing release artifacts to Nexus | +| publish | no | false | Enable/disabling publish artifacts. Must be explicitly set to true to enable publishing. Maybe ignored depending on other factors. | + +## Outputs + +| Output | Description | +|-----------------|-------------| +| artifact_dir | Directory where additional release artifacts can be added by the workflow. They are automatically signed, checksumed, and published at the end of the workflow | + +## Example Workflow + +```yaml +name: Release Candidate + +# triggered via release candidate tags or manually via workflow dispatch, note +# that publishing is disabled if not triggered from a tag +on: + push: + tags: + - 'v*-rc*' + workflow_dispatch: + +jobs: + + release-candidate: + name: Release Candidate ${{ github.ref_name }} + runs-on: ubuntu-latest + + steps: + + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: ASF Release Candidate + id: rc + uses: apache/infrastructure-actions/release-candidate@main + with: + tlp_dir: 'daffodil' + project_name: 'Apache Daffodil' + project_id: 'daffodil' + gpg_signing_key: ${{ secrets.GPG_PRIVATE_KEY }} + svn_username: ${{ secrets.SVN_USERNAME }} + svn_password: ${{ secrets.SVN_PASSWORD }} + nexus_username: ${{ secrets.NEXUS_USERNAME }} + nexus_password: ${{ secrets.NEXUS_PASSWORD }} + publish: true + + - name: Install Dependencies + run: | + sudo apt-get -y install ... + ... + + - name: Create Binary Artifacts + run: | + sbt compile publishSigned ... + + ARTIFACT_DIR=${{ steps.rc.outputs.artifact_dir }} + ARTIFACT_BIN_DIR=$ARTIFACT_DIR/bin + + # copy helper binaries to the artifact bin directory, these will be + # automatically signed, checksumed, and comitted to dist/dev/ + mkdir -p $ARTIFACT_BIN_DIR + cp ... $ARTIFACT_BIN_DIR/ +``` diff --git a/release-candidate/action.yml b/release-candidate/action.yml new file mode 100644 index 00000000..dd2a4143 --- /dev/null +++ b/release-candidate/action.yml @@ -0,0 +1,85 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: 'ASF Release Candidate' +description: > + Action to setup environment and publish ASF release candidates + + Project source must be checked out prior this action. Following steps should + install dependencies, optionally build helper binaries, and optionally + publish artifacts to a maven repository. Helper binaries or additional + artifacts should be written to the directory specified by the `artifact_dir` + output. Maven artifacts should be published with `sbt publishSigned`--this + action configures SBT to publish to eitehr a local repository or the ASF + staging repo depending on if publishing is enabled. + + Workflows using this action should only be triggered when pushing a release + candidate tag of the form 'v*-rcX', or manually dispatched. Manually + dispatched triggers disabling publishing. + + When triggered from a tag, the tag name must match the version found in the + build source configuration with the 'v' and -rc suffix removed. + + The 'publish' input parameter must be explicitly set to true to enable + publishing. Even if true, the action must be trigger from a tag, the version + must not be a SNAPSHOT, and the repository must be an ASF--otherwise + publishing is disabled. + + When the workflow is complete, this action automatically performs a post step + to to signing, checksum, and commit dist artifacts if publishing is enabled. + +inputs: + tlp_dir: + description: 'Directory of the top level project in dist/dev/' + required: true + project_name: + description: 'Human readable name of the project' + required: true + project_id: + description: 'ID of the project, used in source artifact file name' + required: true + project_dir: + description: 'Directory for the project in dev/dist//. Omit if at the root' + required: false + default: "" + gpg_signing_key: + description: 'Key used to sign artifacts' + required: true + svn_username: + description: 'Username for publishing release artifacts to SVN dev/dist' + required: true + svn_password: + description: 'Password for publishing release artifacts to SVN dev/dist' + required: true + nexus_username: + description: 'Username for publishing release artifacts to Nexus' + required: true + nexus_password: + description: 'Password for publishing release artifacts to Nexus' + required: true + publish: + description: 'Enable/disabling publish artifacts. Must be explcitly set to true to enable publishing. Maybe ignored depending on other factors.' + required: false + default: false + +outputs: + artifact_dir: + description: 'Directory where additional release artifacts can be added by the workflow. They are automatically signed, checksumed, and published at the end of the workflow' + +runs: + using: 'node20' + main: 'main.js' + post: 'post.js' + post-if: success() diff --git a/release-candidate/main.js b/release-candidate/main.js new file mode 100644 index 00000000..3ab7600b --- /dev/null +++ b/release-candidate/main.js @@ -0,0 +1,188 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require("fs"); +const os = require("os"); +const core = require("@actions/core"); +const github = require("@actions/github"); +const { exec } = require('@actions/exec'); + +async function run() { + try { + const tlp_dir = core.getInput("tlp_dir", { required: true }); + const project_id = core.getInput("project_id", { required: true }); + const project_dir = core.getInput("project_dir"); + const gpg_signing_key = core.getInput("gpg_signing_key", { required: true }); + const svn_username = core.getInput("svn_username", { required: true }); + const svn_password = core.getInput("svn_password", { required: true }); + const nexus_username = core.getInput("nexus_username", { required: true }); + const nexus_password = core.getInput("nexus_password", { required: true }); + let publish = core.getBooleanInput("publish"); + + // import signing key into gpg and get it's key id + let gpg_import_stdout = "" + await exec("gpg", ["--batch", "--import", "--import-options", "import-show"], { + input: Buffer.from(gpg_signing_key), + listeners: { + stdout: (data) => { gpg_import_stdout += data.toString(); } + } + }); + const gpg_signing_key_id = gpg_import_stdout.match("[0-9A-Z]{40}")[0]; + console.info("Using gpgp key id: " + gpg_signing_key_id); + + // tags must be signed with a committers key, download and import committer + // keys for verification later + let committer_keys = ""; + await exec("curl", [`https://downloads.apache.org/${ tlp_dir }/KEYS`], { + silent: true, + listeners: { + stdout: (data) => { committer_keys += data.toString(); } + } + }); + await exec("gpg", ["--batch", "--import"], { + input: Buffer.from(committer_keys) + }); + + // get the actual project version, this requires a 'VERSION' file at + // the root of the repository + const project_version = fs.readFileSync("VERSION").toString().trim(); + + // figure out the release version. This should follow the pattern + // 'v-rcX', where is the value from the VERSION file + const gitTagPrefix = "refs/tags"; + let release_version = ""; + if (github.context.eventName == "push" && github.context.ref.startsWith(getTagPrefix)) { + // this was triggered by the push of a tag, the tag name will be the + // version used + release_version = github.context.ref.slice(gitTagPrefix.length); + + // make sure the tag name matches the actual project version + if (!release_version.startsWith(`v${project_version}-`)) { + throw new Error(`Tag ${ release_version } does not match project version: v${ project_version }`); + } + + // The github checkout action does not fetch tag information when + // triggered from a tag, so we fetch it manually so we can verify its tag + await exec("git", ["fetch", "origin", "--deepen=1", `+${ github.context.ref }:${ github.context.ref }`]); + + // make sure the tag is signed by a committer in the KEYS file, this + // command fails if the tag does not verify. + await exec("git", ["tag", "--verify", release_version]); + } else { + // this was not triggered by a tag, maybe is was manually triggered via + // workflow_dispatch or a normal commit. We should only publish from tags, + // so we disable publishing. We also set the release_version so that it has the + // same format as a tag (e.g. v1.2.3-rc1) + core.warning("Action not triggered from tag, publishing disabled"); + release_version = `v${ project_version }-rc0`; + publish = false; + } + + const is_snapshot = project_version.includes("-SNAPSHOT"); + + // disable publishing for snapshot builds or non-ASF builds. Note that + // publishing could still be disabled if the publish input was explicitly set + // to false + if (publish && (is_snapshot || process.env.GITHUB_REPOSITORY_OWNER != "apache")) { + core.warning("Publishing disabled for snapshot versions and from non-apache repositories"); + publish = false; + } + + const release_dir = `${ os.tmpdir() }/release`; + fs.mkdirSync(release_dir); + + // enable and configure SBT for signing and publishing. Note that the + // sbt-pgp plugin version should not be updated unless there is a + // compelling reason. Release signing has been known to break with newer + // versions. + const sbt_dir = `${ os.homedir }/.sbt/1.0` + fs.mkdirSync(`${ sbt_dir }/plugins`, { recursive: true }); + fs.appendFileSync(`${ sbt_dir }/plugins/build.sbt`, 'addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2")\n'); + fs.appendFileSync(`${ sbt_dir }/build.sbt`, `pgpSigningKey := Some("${ gpg_signing_key_id }")\n`); + + if (publish) { + // if publishing is enabled, publishing to the apache staging repository + // with the provided credentials. We must diable gigahorse since that fails + // to publish on some systems + fs.appendFileSync(`${ sbt_dir }/build.sbt`, 'ThisBuild / updateOptions := updateOptions.value.withGigahorse(false)\n'); + fs.appendFileSync(`${ sbt_dir }/build.sbt`, `ThisBuild / credentials += Credentials("Sonatype Nexus Repository Manager", "repository.apache.org", "${ nexus_username }", "${ nexus_password }")\n`); + fs.appendFileSync(`${ sbt_dir }/build.sbt`, 'ThisBuild / publishTo := Some("Apache Staging Distribution Repository" at "https://repository.apache.org/service/local/staging/deploy/maven2")\n'); + } else { + // if publishing is not enabled, we still want the ability for workflows to + // run 'sbt publishSigned' so they don't have to change logic depending on + // if they are publishing or not. To support this, configure sbt to publish + // to a local maven repo + const maven_local_dir = `${ release_dir }/maven-local`; + fs.mkdirSync(maven_local_dir); + fs.appendFileSync(`${ sbt_dir }/build.sbt`, `ThisBuild / publishTo := Some(MavenCache("maven-local", file("${ maven_local_dir }")))\n`); + } + + // checkout artifact dist directory + const project_dist_dir = `${ release_dir }/asf-dist`; + await exec("svn", ["checkout", `https://dist.apache.org/repos/dist/dev/${ tlp_dir }/${ project_dir }`, project_dist_dir]); + + // remove previous release candidates of this version (i.e. any + // directories that have the same project_version followed by a + // hyphen). These changes will only be commited if the job succeeds and + // publishing is enabled + const direntries = fs.readdirSync(project_dist_dir, { withFileTypes: true }); + for(const dirent of direntries) { + if (dirent.isDirectory && dirent.name.startsWith(`${ project_version }-`)) { + await exec("svn", ["delete", "--force", `${ dirent.parentPath }/${ dirent.name }`]); + } + } + + // create the directory for artifacts, this is the version without the leading + // 'v', but keeping any -rcX or -SNAPSHOT suffixes + const artifact_dir = `${ project_dist_dir }/${ release_version.slice(1) }`; + fs.mkdirSync(artifact_dir); + + // create the source artifact + const src_artifact_dir = `${ artifact_dir }/src`; + const src_artifact_name = `apache-${ project_id }-${ project_version }-src`; + fs.mkdirSync(src_artifact_dir); + await exec("git", ["archive", "--format=zip", `--prefix=${ src_artifact_name }/`, "--output", `${ src_artifact_dir }/${ src_artifact_name }.zip`, "HEAD"]); + + // get the reproducible build epoch + let source_date_epoch = ""; + await exec("git", ["show", "--no-patch", "--format=%ct", "HEAD"], { + listeners: { + stdout: (data) => { source_date_epoch += data.toString().trim(); } + } + }); + + // we are done with all the filesystem setup, we now export environment + // variables, output variables, and state needed by the post script + + // export environment variables + core.exportVariable("SOURCE_DATE_EPOCH", source_date_epoch); + + // export step output variables + core.setOutput("artifact_dir", artifact_dir); + + // export state information for the post step + core.saveState("artifact_dir", artifact_dir); + core.saveState("gpg_signing_key_id", gpg_signing_key_id); + core.saveState("publish", publish); + core.saveState("release_version", release_version); + + } catch (error) { + core.setFailed(error.message); + } +} + +run(); diff --git a/release-candidate/post.js b/release-candidate/post.js new file mode 100644 index 00000000..0ace41ad --- /dev/null +++ b/release-candidate/post.js @@ -0,0 +1,85 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require("fs"); +const os = require("os"); +const core = require("@actions/core"); +const { DefaultArtifactClient } = require('@actions/artifact') +const { exec } = require('@actions/exec'); + +// Sign and publish all release artifacts. If publishing is disabled, we just +// upload all the release candidate artifacts as GitHub workflow artifacts. +// The post-if condition in action.yml ensures this is only ever run if a job +// succeeds. +async function run() { + try { + const project_name = core.getInput("project_name", { required: true }); + const svn_username = core.getInput("svn_username", { required: true }); + const svn_password = core.getInput("svn_password", { required: true }); + + const artifact_dir = core.getState("artifact_dir"); + const gpg_signing_key_id = core.getState("gpg_signing_key_id"); + const publish = core.getState("publish") === "true"; + const release_version = core.getState("release_version"); + + // sign/checksum all artifacts + const artifacts = fs.readdirSync(artifact_dir, { recursive: true, withFileTypes: true }); + for(const artifact of artifacts) { + if (artifact.isFile()) { + // must sign rpms before sha/gpg since rpmsign modifies the RPM + if (artifact.name.endsWith(".rpm")) { + await exec("rpmsign", ["--define", `_gpg_name ${ gpg_signing_key_id }`, "--define", "_binary_filedigest_algorithm 10", "--addsign", `${ artifact.parentPath }/${ artifact.name }`]); + } + let checksum = ""; + await exec("sha512sum", ["--binary", artifact.name], { + cwd: artifact.parentPath, + listeners: { + stdout: (data) => { checksum += data.toString(); } + } + }); + fs.appendFileSync(`${ artifact.name }.sha512`, checksum); + await exec("gpg", ["--default-key", gpg_signing_key_id, "--batch", "--yes", "--detach-sign", "--armor", "--output", `${ artifact.name }.asc`, artifact.name], { + cwd: artifact.parentPath + }); + } + } + + if (publish) { + await exec("svn", ["add", artifact_dir]); + await exec("svn", ["commit", "--username", svn_username, "--password", svn_password, "--message", `Stage ${ project_name } ${ release_version }`, artifact_dir]); + } else { + // if publishing was disabled then this action was likely just triggered + // just for testing, so upload the maven-local and artifact directories so + // they can be verified + const release_dir = `${ os.tmpdir() }/release`; + const upload_artifacts = fs.readdirSync(release_dir, { recursive: true, withFileTypes: true }) + .filter((dirent) => dirent.isFile()) + .filter((dirent) => !dirent.parentPath.split("/").includes(".svn")) + .map((dirent) => `${ dirent.parentPath }/${ dirent.name }`); + const artifact_client = new DefaultArtifactClient(); + artifact_client.uploadArtifact(`release`, upload_artifacts, os.tmpdir(), { + compressionLevel: 0, + retentionDays: 1 + }); + } + + } catch (error) { + core.setFailed(error.message); + } +} + +run();