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: add access, fix provenance if new & unscoped #305

Merged
merged 18 commits into from
May 25, 2023
Merged
Show file tree
Hide file tree
Changes from 13 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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +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.
- _(Optional)_ If `provenance: true`, 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 @@ -232,6 +232,7 @@ If `provenance: true` is added to your `release.yml`'s **inputs**, NPM will [gen
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**
- The package must have public access.
- 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"`.

Expand Down Expand Up @@ -284,7 +285,8 @@ jobs:
| `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`_) |
| `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`_)
| `access`| No | Set as `public` or `restricted` to change an NPM package's access status when next published.


## Motivation
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ inputs:
required: false
type: boolean
default: 'false'
access:
description: 'If defined, sets package to public or restricted via `--access` flag on `npm publish`. Supported values are "restricted" or "public".'
required: false

runs:
using: 'composite'
Expand Down
164 changes: 132 additions & 32 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -73459,7 +73459,7 @@ class SemVer {
version = version.version
}
} else if (typeof version !== 'string') {
throw new TypeError(`Invalid Version: ${(__nccwpck_require__(3837).inspect)(version)}`)
throw new TypeError(`Invalid version. Must be a string. Got type "${typeof version}".`)
}

if (version.length > MAX_LENGTH) {
Expand Down Expand Up @@ -78934,6 +78934,7 @@ module.exports = {
ZIP_EXTENSION: '.zip',
APP_NAME: 'optic-release-automation[bot]',
AUTO_INPUT: 'auto',
ACCESS_OPTIONS: ['public', 'restricted'],
}


Expand Down Expand Up @@ -79211,7 +79212,7 @@ module.exports = async function ({ context, inputs, packageVersion }) {
const core = __nccwpck_require__(2186)
const semver = __nccwpck_require__(1383)

const { PR_TITLE_PREFIX } = __nccwpck_require__(6818)
const { ACCESS_OPTIONS, PR_TITLE_PREFIX } = __nccwpck_require__(6818)
const { callApi } = __nccwpck_require__(4235)
const { tagVersionInGit } = __nccwpck_require__(9143)
const { revertCommit } = __nccwpck_require__(5765)
Expand All @@ -79220,7 +79221,7 @@ const { notifyIssues } = __nccwpck_require__(8361)
const { logError, logInfo, logWarning } = __nccwpck_require__(653)
const { execWithOutput } = __nccwpck_require__(8632)
const {
checkProvenanceViability,
ensureProvenanceViability,
getNpmVersion,
} = __nccwpck_require__(3365)

Expand Down Expand Up @@ -79308,22 +79309,41 @@ module.exports = async function ({ github, context, inputs }) {
const opticToken = inputs['optic-token']
const npmToken = inputs['npm-token']
const provenance = /true/i.test(inputs['provenance'])
const access = inputs['access']

// Can't limit custom action inputs to fixed options like "choice" type in a manual action
if (access && !ACCESS_OPTIONS.includes(access)) {
core.setFailed(
`Invalid "access" option provided ("${access}"), should be one of "${ACCESS_OPTIONS.join(
'", "'
)}"`
)
return
}

const publishOptions = {
npmToken,
opticToken,
opticUrl,
npmTag,
version,
provenance,
access,
}

// Fail fast with meaningful error if user wants provenance but their setup won't deliver
if (provenance) {
// Fail fast with meaningful error if user wants provenance but their setup won't deliver,
// and apply any necessary options tweaks.
const npmVersion = await getNpmVersion()
checkProvenanceViability(npmVersion)

Object.assign(
publishOptions,
await ensureProvenanceViability(npmVersion, publishOptions)
)
}

if (npmToken) {
await publishToNpm({
npmToken,
opticToken,
opticUrl,
npmTag,
version,
provenance,
})
await publishToNpm(publishOptions)
} else {
logWarning('missing npm-token')
}
Expand Down Expand Up @@ -79660,11 +79680,11 @@ exports.execWithOutput = execWithOutput
"use strict";


const fs = __nccwpck_require__(7147)
const pMap = __nccwpck_require__(1855)
const { logWarning } = __nccwpck_require__(653)

const { getPrNumbersFromReleaseNotes } = __nccwpck_require__(4098)
const { getLocalInfo } = __nccwpck_require__(4349)

async function getLinkedIssueNumbers(github, prNumber, repoOwner, repoName) {
const data = await github.graphql(
Expand Down Expand Up @@ -79747,8 +79767,7 @@ async function notifyIssues(
repo,
release
) {
const packageJsonFile = fs.readFileSync('./package.json', 'utf8')
const packageJson = JSON.parse(packageJsonFile)
const packageJson = getLocalInfo()

const { name: packageName, version: packageVersion } = packageJson
const { body: releaseNotes, html_url: releaseUrl } = release
Expand Down Expand Up @@ -79800,6 +79819,52 @@ async function notifyIssues(
exports.notifyIssues = notifyIssues


/***/ }),

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

"use strict";

const fs = __nccwpck_require__(7147)
const { execWithOutput } = __nccwpck_require__(8632)

/**
* Get info from the registry about a package that is already published.
*
* Returns null if package is not published to NPM.
*/
async function getPublishedInfo() {
try {
const packageInfo = await execWithOutput('npm', ['view', '--json'])
return packageInfo ? JSON.parse(packageInfo) : null
} catch (error) {
if (!error?.message?.match(/code E404/)) {
throw error
}
return null
}
}

/**
* Get info from the local package.json file.
*
* This might need to become a bit more sophisticated if support for monorepos is added,
* @see https://github.com/nearform-actions/optic-release-automation-action/issues/177
*/
function getLocalInfo() {
const packageJsonFile = fs.readFileSync('./package.json', 'utf8')
const packageInfo = JSON.parse(packageJsonFile)

return packageInfo
}

module.exports = {
getLocalInfo,
getPublishedInfo,
}


/***/ }),

/***/ 3365:
Expand All @@ -79809,6 +79874,7 @@ exports.notifyIssues = notifyIssues

const semver = __nccwpck_require__(1383)
const { execWithOutput } = __nccwpck_require__(8632)
const { getLocalInfo, getPublishedInfo } = __nccwpck_require__(4349)

/**
* Abort if the user specified they want NPM provenance, but their CI's NPM version doesn't support it.
Expand Down Expand Up @@ -79848,18 +79914,51 @@ function checkPermissions(npmVersion) {
}

/**
* Fail fast and throw a meaningful error if NPM Provenance will fail silently or misleadingly.
* NPM does an internal check on access that fails unnecessarily for first-time publication
* of unscoped packages to NPM. Unscoped packages are always public, but NPM's provenance generation
* doesn't realise this unless it sees the status in a previous release or in explicit options.
*/
async function getAccessAdjustment({ access } = {}) {
// Don't overrule any user-set access preference.
if (access) return

const { name: packageName, publishConfig } = getLocalInfo()

// Don't do anything for scoped packages - those require being made public explicitly.
// Let NPM's own validation handle it if a user tries to get provenance on a private package.
// `.startsWith('@')` is what a lot of NPM internal code use to detect scoped packages,
// they don't export any more sophisticated scoped name detector any more.
if (packageName.startsWith('@')) return

// Don't do anything if the user has set any access control in package.json publishConfig.
// https://docs.npmjs.com/cli/v9/configuring-npm/package-json#publishconfig
// Let NPM deal with that internally when `npm publish` reads the local package.json file.
if (publishConfig?.access) return

// Don't do anything if package is already published.
const publishedInfo = await getPublishedInfo()
if (publishedInfo) return

// Set explicit public access **only** if it's unscoped (inherently public), a first publish
// (so we know NPM will fail to realise that this is inherently public), and the user
// has not attempted to explicitly set access themselves anywhere.
return { access: 'public' }
}

/**
* Fail fast and throw a meaningful error if NPM Provenance will fail silently or misleadingly,
* and where necessary, provide new publish options without overriding user preferences or expectations.
*
* @see https://docs.npmjs.com/generating-provenance-statements
*
* @param {string} npmVersion
*/
function checkProvenanceViability(npmVersion) {
async function ensureProvenanceViability(npmVersion, publishOptions) {
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.

const optionAdjustments = await getAccessAdjustment(publishOptions)

return optionAdjustments ?? {}
}

/**
Expand All @@ -79871,8 +79970,9 @@ async function getNpmVersion() {
}

module.exports = {
checkProvenanceViability,
ensureProvenanceViability,
getNpmVersion,
getAccessAdjustment,
checkIsSupported,
checkPermissions,
}
Expand All @@ -79887,21 +79987,15 @@ module.exports = {


const { execWithOutput } = __nccwpck_require__(8632)
const { getPublishedInfo } = __nccwpck_require__(4349)

async function allowNpmPublish(version) {
// We need to check if the package was already published. This can happen if
// the action was already executed before, but it failed in its last step
// (GH release).
let packageName = null
try {
const packageInfo = await execWithOutput('npm', ['view', '--json'])
packageName = packageInfo ? JSON.parse(packageInfo).name : null
} catch (error) {
if (!error?.message?.match(/code E404/)) {
throw error
}
}

const packageInfo = await getPublishedInfo()
const packageName = packageInfo?.name
// Package has not been published before
if (!packageName) {
return true
Expand Down Expand Up @@ -79935,6 +80029,7 @@ async function publishToNpm({
npmTag,
version,
provenance,
access,
}) {
await execWithOutput('npm', [
'config',
Expand All @@ -79943,6 +80038,11 @@ async function publishToNpm({
])

const flags = ['--tag', npmTag]

if (access) {
flags.push('--access', access)
}

if (provenance) {
flags.push('--provenance')
}
Expand Down
1 change: 1 addition & 0 deletions src/const.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ module.exports = {
ZIP_EXTENSION: '.zip',
APP_NAME: 'optic-release-automation[bot]',
AUTO_INPUT: 'auto',
ACCESS_OPTIONS: ['public', 'restricted'],
}
43 changes: 31 additions & 12 deletions src/release.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const core = require('@actions/core')
const semver = require('semver')

const { PR_TITLE_PREFIX } = require('./const')
const { ACCESS_OPTIONS, PR_TITLE_PREFIX } = require('./const')
const { callApi } = require('./utils/callApi')
const { tagVersionInGit } = require('./utils/tagVersion')
const { revertCommit } = require('./utils/revertCommit')
Expand All @@ -12,7 +12,7 @@ const { notifyIssues } = require('./utils/notifyIssues')
const { logError, logInfo, logWarning } = require('./log')
const { execWithOutput } = require('./utils/execWithOutput')
const {
checkProvenanceViability,
ensureProvenanceViability,
getNpmVersion,
} = require('./utils/provenance')

Expand Down Expand Up @@ -100,22 +100,41 @@ module.exports = async function ({ github, context, inputs }) {
const opticToken = inputs['optic-token']
const npmToken = inputs['npm-token']
const provenance = /true/i.test(inputs['provenance'])
const access = inputs['access']

// Can't limit custom action inputs to fixed options like "choice" type in a manual action
Copy link
Member

Choose a reason for hiding this comment

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

I think this comment is somewhat redundant as it's fairly obvious that you can't restrict what inputs are provided to an action

if (access && !ACCESS_OPTIONS.includes(access)) {
core.setFailed(
`Invalid "access" option provided ("${access}"), should be one of "${ACCESS_OPTIONS.join(
'", "'
)}"`
)
return
}

const publishOptions = {
npmToken,
opticToken,
opticUrl,
npmTag,
version,
provenance,
access,
}

// Fail fast with meaningful error if user wants provenance but their setup won't deliver
if (provenance) {
// Fail fast with meaningful error if user wants provenance but their setup won't deliver,
// and apply any necessary options tweaks.
const npmVersion = await getNpmVersion()
checkProvenanceViability(npmVersion)

Object.assign(
publishOptions,
await ensureProvenanceViability(npmVersion, publishOptions)
)
}

if (npmToken) {
await publishToNpm({
npmToken,
opticToken,
opticUrl,
npmTag,
version,
provenance,
})
await publishToNpm(publishOptions)
} else {
logWarning('missing npm-token')
}
Expand Down
Loading