Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(issue 285): Add provenance support #294

Merged
merged 19 commits into from
May 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This action allows you to automate the release process of your npm modules, apps
- [Example](#example-1)
- [How to add a build step to your workflow](#how-to-add-a-build-step-to-your-workflow)
- [Prerelease support](#prerelease-support)
- [Provenance](#provenance)
- [Inputs](#inputs)
- [Motivation](#motivation)
- [Playground / Testing](#playground--testing)
Expand Down Expand Up @@ -83,14 +84,20 @@ jobs:
contents: write
issues: write
pull-requests: write
# optional: `id-token: write` permission is required if `provenance: true` is set below
id-token: write
steps:
- uses: nearform-actions/optic-release-automation-action@v4
with:
npm-token: ${{ secrets.NPM_TOKEN }}
optic-token: ${{ secrets.OPTIC_TOKEN }}
commit-message: ${{ github.event.inputs.commit-message }}
semver: ${{ github.event.inputs.semver }}
npm-tag: ${{ github.event.inputs.tag }}
# optional: set this secret in your repo config for publishing to NPM
npm-token: ${{ secrets.NPM_TOKEN }}
# optional: set this secret in your repo config for 2FA with Optic
simoneb marked this conversation as resolved.
Show resolved Hide resolved
optic-token: ${{ secrets.OPTIC_TOKEN }}
# optional: NPM will generate provenance statement, or abort release if it can't
provenance: true
```

The above workflow (when manually triggered) will:
Expand All @@ -110,6 +117,7 @@ When you merge this PR:
- Upon successful retrieval of the OTP, it will publish the package to Npm.
- Create a Github release with change logs (You can customize release notes using [release.yml](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#example-configuration))
- Leave a comment on each issues that are linked to the pull reqeuests of this release. This feature can be turned off by the `notify-on-the-issue` flag.
- _(Optional)_ If `provenance: true` was set, NPM will add a [Provenance](#provenance) notice to the package's public NPM page.

When you close the PR without merging it: nothing will happen.

Expand Down Expand Up @@ -217,6 +225,43 @@ Generally, if you want to release a prerelease of a repository, and it is an NPM

Please note that in case of a prerelease the `sync-semver-tags` input will be treated as `false`, even if it's set to `true`. This because we don't want to update the main version tags to the latest prerelease commit but only to the latest official release.

## Provenance
simoneb marked this conversation as resolved.
Show resolved Hide resolved

If `provenance: true` is added to your `release.yml`'s **inputs**, NPM will [generate a provenance statement](https://docs.npmjs.com/generating-provenance-statements).

NPM has some internal [requirements](https://docs.npmjs.com/generating-provenance-statements#prerequisites) for generating provenance. Unfortunately as of May 2023, not all are documented by NPM; some key requirements are:

- `id-token: write` must be added to your `release.yml`'s **permissions**
- NPM must be on version 9.5.0 or greater (this will be met if our recommended `runs-on: ubuntu-latest` is used)
- NPM has some undocumented internal requirements on `package.json` completeness. For example, the [repository field](https://docs.npmjs.com/cli/v9/configuring-npm/package-json#repository) is required, and some NPM versions may require its `"url"` property to match the format `"git+https://github.com/user/repo"`.

If any requirements are not met, the release will be aborted before publishing the new version, and an appropriate error will be shown in the actions report. The release commit can be reverted and the action re-tried after fixing the issue highlighted in the logged error.

The above [example yml action](#example) includes support for Provenance. To add provenance support to an existing action, add these two lines:

```yml
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
# add this permission which is required for provenance
id-token: write
steps:
- uses: nearform-actions/optic-release-automation-action@v4
with:
npm-token: ${{ secrets.NPM_TOKEN }}
optic-token: ${{ secrets.OPTIC_TOKEN }}
commit-message: ${{ github.event.inputs.commit-message }}
semver: ${{ github.event.inputs.semver }}
npm-tag: ${{ github.event.inputs.tag }}
# add this to activate the action's provenance feature
provenance: true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if there's a way to highlight certain lines using github code highlighting

Copy link
Contributor Author

@AlanSl AlanSl May 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems not, sadly:

```


## Inputs

| Input | Required | Description |
Expand All @@ -239,6 +284,8 @@ Please note that in case of a prerelease the `sync-semver-tags` input will be tr
| `version-prefix` | No | A prefix to apply to the version number, which reflects in the tag and GitHub release names. <br /> (_Default: 'v'_) |
| `prerelease-prefix` | No | A prefix to apply to the prerelease version number. |
| `base-tag` | No | Choose a specific tag release for your release notes. This input allows you to specify a base release (for example, v1.0.0) and will include all changes made in releases between the base release and the latest release. This input is only used for generating release notes and has no functional implications on the rest of the workflow. |
| `provenance`| No | Set as true to have NPM [generate a provenance statement](https://docs.npmjs.com/generating-provenance-statements). See [Provenance section above](#provenance) for requirements.<br /> (_Default: `false`_) |


## Motivation

Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ inputs:
base-tag:
description: 'This input allows you to specify a base release and will include all changes made in releases between the base release and the latest release'
required: false
provenance:
description: 'If true, NPM >9.5 will attempt to generate and display a "provenance" badge. See https://docs.npmjs.com/generating-provenance-statements'
required: false
type: boolean
default: 'false'

runs:
using: 'composite'
Expand Down
118 changes: 111 additions & 7 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79219,6 +79219,10 @@ const { publishToNpm } = __nccwpck_require__(1433)
const { notifyIssues } = __nccwpck_require__(8361)
const { logError, logInfo, logWarning } = __nccwpck_require__(653)
const { execWithOutput } = __nccwpck_require__(8632)
const {
checkProvenanceViability,
getNpmVersion,
} = __nccwpck_require__(3365)

module.exports = async function ({ github, context, inputs }) {
logInfo('** Starting Release **')
Expand Down Expand Up @@ -79303,9 +79307,23 @@ module.exports = async function ({ github, context, inputs }) {
try {
const opticToken = inputs['optic-token']
const npmToken = inputs['npm-token']
const provenance = /true/i.test(inputs['provenance'])

// Fail fast with meaningful error if user wants provenance but their setup won't deliver
if (provenance) {
const npmVersion = await getNpmVersion()
checkProvenanceViability(npmVersion)
}

if (npmToken) {
await publishToNpm({ npmToken, opticToken, opticUrl, npmTag, version })
await publishToNpm({
npmToken,
opticToken,
opticUrl,
npmTag,
version,
provenance,
})
} else {
logWarning('missing npm-token')
}
Expand Down Expand Up @@ -79558,16 +79576,18 @@ const { exec } = __nccwpck_require__(1514)
* @param {{cwd?: string}} options
* @returns Promise<string>
*/
async function execWithOutput(cmd, args, { cwd } = {}) {
async function execWithOutput(
cmd,
args,
{ cwd, silent = false, ...options } = {}
) {
let output = ''
let errorOutput = ''

const stdoutDecoder = new StringDecoder('utf8')
const stderrDecoder = new StringDecoder('utf8')

const options = {
silent: false,
}
options.silent = silent

/* istanbul ignore else */
if (cwd !== '') {
Expand Down Expand Up @@ -79764,6 +79784,84 @@ async function notifyIssues(
exports.notifyIssues = notifyIssues


/***/ }),

/***/ 3365:
/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => {

"use strict";

const semver = __nccwpck_require__(1383)
const { execWithOutput } = __nccwpck_require__(8632)

/**
* Abort if the user specified they want NPM provenance, but their CI's NPM version doesn't support it.
* If we continued, the release will go ahead with no warnings, and no provenance will be generated.
*/
function checkIsSupported(npmVersion) {
const validNpmVersion = '>=9.5.0'

if (!semver.satisfies(npmVersion, validNpmVersion)) {
throw new Error(
`Provenance requires NPM ${validNpmVersion}, but this action is using v${npmVersion}.
Either remove provenance from your release action's inputs, or update your release CI's NPM version.`
)
}
}

/**
* Abort with a meaningful error if the user would get a misleading error message from NPM
* due to an NPM bug that existed between 9.5.0 and 9.6.1.
* As of April 2023, this would affect anyone whose CI is set to Node 18 (which defaults to NPM 9.5.1).
*/
function checkPermissions(npmVersion) {
// Bug was fixed in this NPM version - see https://github.com/npm/cli/pull/6226
const correctNpmErrorVersion = '>=9.6.1'

if (
// Same test condition as in fixed versions of NPM
!process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
// Let NPM handle this itself after their bug was fixed, so we're not brittle against future changes
!semver.satisfies(npmVersion, correctNpmErrorVersion)
) {
throw new Error(
// Same error message as in fixed versions of NPM
'Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'
)
}
}

/**
* Fail fast and throw a meaningful error if NPM Provenance will fail silently or misleadingly.
*
* @see https://docs.npmjs.com/generating-provenance-statements
*
* @param {string} npmVersion
*/
function checkProvenanceViability(npmVersion) {
if (!npmVersion) throw new Error('Current npm version not provided')
checkIsSupported(npmVersion)
checkPermissions(npmVersion)
// There are various other provenance requirements, such as specific package.json properties, but these
// may change in future NPM versions, and do fail with meaningful errors, so we let NPM handle those.
}

/**
* Gets npm version via `npm -v` on the command line.
* Split out as its own export so it can be easily mocked in tests.
*/
async function getNpmVersion() {
return execWithOutput('npm', ['-v'])
}

module.exports = {
checkProvenanceViability,
getNpmVersion,
checkIsSupported,
checkPermissions,
}


/***/ }),

/***/ 1433:
Expand Down Expand Up @@ -79820,23 +79918,29 @@ async function publishToNpm({
opticUrl,
npmTag,
version,
provenance,
}) {
await execWithOutput('npm', [
'config',
'set',
`//registry.npmjs.org/:_authToken=${npmToken}`,
])

const flags = ['--tag', npmTag]
if (provenance) {
flags.push('--provenance')
}

if (await allowNpmPublish(version)) {
await execWithOutput('npm', ['pack', '--dry-run'])
if (opticToken) {
const otp = await execWithOutput('curl', [
'-s',
`${opticUrl}${opticToken}`,
])
await execWithOutput('npm', ['publish', '--otp', otp, '--tag', npmTag])
await execWithOutput('npm', ['publish', '--otp', otp, ...flags])
} else {
await execWithOutput('npm', ['publish', '--tag', npmTag])
await execWithOutput('npm', ['publish', ...flags])
}
}
}
Expand Down
20 changes: 19 additions & 1 deletion src/release.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const { publishToNpm } = require('./utils/publishToNpm')
const { notifyIssues } = require('./utils/notifyIssues')
const { logError, logInfo, logWarning } = require('./log')
const { execWithOutput } = require('./utils/execWithOutput')
const {
checkProvenanceViability,
getNpmVersion,
} = require('./utils/provenance')

module.exports = async function ({ github, context, inputs }) {
logInfo('** Starting Release **')
Expand Down Expand Up @@ -95,9 +99,23 @@ module.exports = async function ({ github, context, inputs }) {
try {
const opticToken = inputs['optic-token']
const npmToken = inputs['npm-token']
const provenance = /true/i.test(inputs['provenance'])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: for boolean inputs @actions/core contains a utility (but I believe what you're doing here works just fine and I'm fine with it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the pattern used elsewhere in the project. I really don't like it... so I'd be happy to swap all instances of this for the actions utility?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe that's better done as a small separate PR since it affects code out of scope of this feature.


// Fail fast with meaningful error if user wants provenance but their setup won't deliver
if (provenance) {
const npmVersion = await getNpmVersion()
checkProvenanceViability(npmVersion)
}

if (npmToken) {
await publishToNpm({ npmToken, opticToken, opticUrl, npmTag, version })
await publishToNpm({
npmToken,
opticToken,
opticUrl,
npmTag,
version,
provenance,
})
} else {
logWarning('missing npm-token')
}
Expand Down
10 changes: 6 additions & 4 deletions src/utils/execWithOutput.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ const { exec } = require('@actions/exec')
* @param {{cwd?: string}} options
* @returns Promise<string>
*/
async function execWithOutput(cmd, args, { cwd } = {}) {
async function execWithOutput(
cmd,
args,
{ cwd, silent = false, ...options } = {}
) {
let output = ''
let errorOutput = ''

const stdoutDecoder = new StringDecoder('utf8')
const stderrDecoder = new StringDecoder('utf8')

const options = {
silent: false,
}
options.silent = silent

/* istanbul ignore else */
if (cwd !== '') {
Expand Down
Loading