Skip to content

Commit c404aee

Browse files
authored
Release from CI (#178)
## Problem A good release process needs to be fast while also ensuring quality and consistency. Confidence of quality comes from incorporating automated testing. And there are many aspects of consistency including: - Ensuring commits are tagged with version number so we can know what is in each release (now and in the future in case of any security reports) - Version number in `pinecone/__version__` is correctly updated, so we don't try to reuse the same one later - Building in a standardized way to avoid introducing accidental inconsistencies (e.g. uncommitted and untested local changes accidentally included in a release, using an old version of python on a local machine, etc) ## Solution Implement github actions workflows for publishing releases (`release.yaml`) and prereleases (`alpha-release.yaml`). <img width="649" alt="Screenshot 2023-06-06 at 3 01 23 PM" src="https://github.com/pinecone-io/pinecone-python-client/assets/1326365/79e2464a-832c-447d-a771-146dc6d60ecf"> These workflows both follow the same high-level steps that take place in a `publish-to-pypi.yaml` [reusable workflow](https://docs.github.com/en/actions/using-workflows/reusing-workflows): - Run tests using another new reusable workflow, `testing.yaml`, which I refactored out of the existing PR workflow. - Automatic version bumps based on type of release (`major`, `minor`, `patch`). - I ended up writing my own small action to do this because I didn't find a nice github action that would do this off the shelf. There are lots of small code packages out there that do the version bump logic but they tended to have weird flaws that made them a poor fit (e.g. wanting to handle the git commit part even though this isn't the right moment for it in my workflow, wanting to store separate version-related config elsewhere, poor support for custom alpha/rc version numbers, etc.) - Check that version numbers are unique (i.e. that there is no preexisting git tag with that number. This mainly protects us from accidentally reusing the same rc number). This validation is possible since git tags become the source of truth on which version numbers are used/available. - Build with existing make task - Upload to PyPI using twine. This is no change, just using Makefile tasks defined by others in the past. - Git push commit with updated `pinecone/__version__` and git tags. Save this step for last so that the git tag is not consumed unless the pypi upload is successful. ## Other notes Updated the version saved in `pinecone/__version__` because I noticed that it is out of date with the latest version published on PyPI. This is one example of a type of error that will no longer occur when publishing from CI. While implementing this I ran into several different issues in github actions including: - actions/runner#2472 - actions/runner#1483 - actions/hello-world-javascript-action#12 ## Future Work Some ideas for future: - I would like to make some automated changelog updates and draft release notes from CI. But not doing this now as this is already a large change. - I could also do some work to figure out what the automatic next rc number is by inspecting what git tags have been used. But for now it's easy to just pass in the values as an input when starting the job. ## Type of Change - [x] Infrastructure change (CI configs, etc) ## Test Plan I used the test environment at test.pypi.org to publish an rc version of this package using the `alpha-release.yaml` version of the workflow. Here's a [test run](https://github.com/pinecone-io/pinecone-python-client/actions/runs/5192266625) I did that targeted the testpypi and resulted in [this 2.2.2rc4 artifact](https://test.pypi.org/project/pinecone-client/2.2.2rc4/). Which can be installed like this by specifying the test.pypi.org index: ``` pip install -i https://test.pypi.org/simple/ pinecone-client==2.2.2rc4 ``` In general, to test a github workflow that only exists in a branch you need to install the github CLI and run a command like this from inside the project: ``` gh workflow run alpha-release.yaml -f ref=jhamon/release-from-ci -f releaseLevel=patch -f prereleaseSuffix='rc4' ``` The only thing I changed after this run was to switch from targeting the test PyPI index to the production PyPI index. The prod workflow is substantially similar so I have a high confidence it should work but I don't want to test it and release until I land this in master because I want to the release commit to tag a SHA in the main history (and not this branch). I plan to land, then make a follow up for any small issues that come up when trying to use it for the first time.
1 parent 9d9251e commit c404aee

File tree

15 files changed

+3937
-101
lines changed

15 files changed

+3937
-101
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const core = require('./core');
2+
3+
function bumpVersion(currentVersion, bumpType, prerelease) {
4+
let newVersion = calculateNewVersion(currentVersion, bumpType);
5+
6+
if (prerelease) {
7+
newVersion = `${newVersion}.${prerelease}`;
8+
}
9+
core.setOutput('previous_version', currentVersion)
10+
core.setOutput('previous_version_tag', `v${currentVersion}`)
11+
core.setOutput('version', newVersion);
12+
core.setOutput('version_tag', `v${newVersion}`)
13+
14+
return newVersion;
15+
}
16+
17+
function calculateNewVersion(currentVersion, bumpType) {
18+
const [major, minor, patch] = currentVersion.split('.');
19+
let newVersion;
20+
21+
switch (bumpType) {
22+
case 'major':
23+
newVersion = `${parseInt(major) + 1}.0.0`;
24+
break;
25+
case 'minor':
26+
newVersion = `${major}.${parseInt(minor) + 1}.0`;
27+
break;
28+
case 'patch':
29+
newVersion = `${major}.${minor}.${parseInt(patch) + 1}`;
30+
break;
31+
default:
32+
throw new Error(`Invalid bumpType: ${bumpType}`);
33+
}
34+
35+
return newVersion;
36+
}
37+
38+
module.exports = { bumpVersion }
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const action = require('./action')
2+
const core = require('./core');
3+
4+
jest.mock('./core');
5+
6+
describe('bump-version', () => {
7+
test('bump major', () => {
8+
action.bumpVersion('1.2.3', 'major', '')
9+
10+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '1.2.3')
11+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v1.2.3')
12+
expect(core.setOutput).toHaveBeenCalledWith('version', '2.0.0');
13+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v2.0.0')
14+
})
15+
16+
test('bump minor: existing minor and patch', () => {
17+
action.bumpVersion('1.2.3', 'minor', '');
18+
19+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '1.2.3')
20+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v1.2.3')
21+
expect(core.setOutput).toHaveBeenCalledWith('version', '1.3.0');
22+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v1.3.0')
23+
})
24+
25+
test('bump minor: with no patch', () => {
26+
action.bumpVersion('1.2.0', 'minor', '');
27+
28+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '1.2.0')
29+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v1.2.0')
30+
expect(core.setOutput).toHaveBeenCalledWith('version', '1.3.0');
31+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v1.3.0')
32+
})
33+
34+
test('bump minor: from existing patch', () => {
35+
action.bumpVersion('2.2.3', 'minor', '');
36+
37+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '2.2.3')
38+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v2.2.3')
39+
expect(core.setOutput).toHaveBeenCalledWith('version', '2.3.0');
40+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v2.3.0')
41+
})
42+
43+
test('bump patch: existing patch', () => {
44+
action.bumpVersion('1.2.3', 'patch', '');
45+
46+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '1.2.3')
47+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v1.2.3')
48+
expect(core.setOutput).toHaveBeenCalledWith('version', '1.2.4');
49+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v1.2.4')
50+
})
51+
52+
test('bump patch: minor with no patch', () => {
53+
action.bumpVersion('1.2.0', 'patch', '');
54+
55+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '1.2.0')
56+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v1.2.0')
57+
expect(core.setOutput).toHaveBeenCalledWith('version', '1.2.1');
58+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v1.2.1')
59+
})
60+
61+
test('bump patch: major with no minor or patch', () => {
62+
action.bumpVersion('1.0.0', 'patch', '');
63+
64+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '1.0.0')
65+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v1.0.0')
66+
expect(core.setOutput).toHaveBeenCalledWith('version', '1.0.1');
67+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v1.0.1')
68+
})
69+
70+
test('bump patch: major with minor', () => {
71+
action.bumpVersion('1.1.0', 'patch', '');
72+
73+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '1.1.0')
74+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v1.1.0')
75+
expect(core.setOutput).toHaveBeenCalledWith('version', '1.1.1');
76+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v1.1.1')
77+
})
78+
79+
test('prerelease suffix provided', () => {
80+
action.bumpVersion('1.2.3', 'patch', 'rc1');
81+
82+
expect(core.setOutput).toHaveBeenCalledWith('previous_version', '1.2.3')
83+
expect(core.setOutput).toHaveBeenCalledWith('previous_version_tag', 'v1.2.3')
84+
expect(core.setOutput).toHaveBeenCalledWith('version', '1.2.4.rc1');
85+
expect(core.setOutput).toHaveBeenCalledWith('version_tag', 'v1.2.4.rc1')
86+
})
87+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: 'pinecone-io/bump-version'
2+
3+
description: 'Bumps the version number in a file'
4+
5+
inputs:
6+
versionFile:
7+
description: 'Path to a file containing the version number'
8+
required: true
9+
bumpType:
10+
description: 'The type of version bump (major, minor, patch)'
11+
required: true
12+
prereleaseSuffix:
13+
description: 'Optional prerelease identifier to append to the version number'
14+
required: false
15+
default: ''
16+
17+
outputs:
18+
version:
19+
description: 'The new version number'
20+
version_tag:
21+
description: 'The new version tag'
22+
previous_version:
23+
description: 'The previous version number'
24+
previous_version_tag:
25+
description: 'The previous version tag'
26+
27+
runs:
28+
using: 'node16'
29+
main: 'index.js'

.github/actions/bump-version/core.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copied these commands out of the github actions toolkit
2+
// because actually depending on @actions/core requires me to check
3+
// in node_modules and 34MB of dependencies, which I don't want to do.
4+
5+
const fs = require('fs');
6+
const os = require('os');
7+
8+
function getInput(name, options) {
9+
const val =
10+
process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] || ''
11+
if (options && options.required && !val) {
12+
throw new Error(`Input required and not supplied: ${name}`)
13+
}
14+
15+
if (options && options.trimWhitespace === false) {
16+
return val
17+
}
18+
19+
return val.trim()
20+
}
21+
22+
function toCommandValue(input) {
23+
if (input === null || input === undefined) {
24+
return ''
25+
} else if (typeof input === 'string' || input instanceof String) {
26+
return input
27+
}
28+
return JSON.stringify(input)
29+
}
30+
31+
function prepareKeyValueMessage(key, value) {
32+
const delimiter = `delimiter_${Math.floor(Math.random()*100000)}`
33+
const convertedValue = toCommandValue(value)
34+
35+
// These should realistically never happen, but just in case someone finds a
36+
// way to exploit uuid generation let's not allow keys or values that contain
37+
// the delimiter.
38+
if (key.includes(delimiter)) {
39+
throw new Error(
40+
`Unexpected input: name should not contain the delimiter "${delimiter}"`
41+
)
42+
}
43+
44+
if (convertedValue.includes(delimiter)) {
45+
throw new Error(
46+
`Unexpected input: value should not contain the delimiter "${delimiter}"`
47+
)
48+
}
49+
50+
return `${key}<<${delimiter}${os.EOL}${convertedValue}${os.EOL}${delimiter}`
51+
}
52+
53+
function setOutput(name, value) {
54+
const filePath = process.env['GITHUB_OUTPUT'] || ''
55+
if (filePath) {
56+
return issueFileCommand('OUTPUT', prepareKeyValueMessage(name, value))
57+
}
58+
59+
process.stdout.write(os.EOL)
60+
issueCommand('set-output', {name}, toCommandValue(value))
61+
}
62+
63+
function issueFileCommand(command, message) {
64+
const filePath = process.env[`GITHUB_${command}`]
65+
if (!filePath) {
66+
throw new Error(
67+
`Unable to find environment variable for file command ${command}`
68+
)
69+
}
70+
if (!fs.existsSync(filePath)) {
71+
throw new Error(`Missing file at path: ${filePath}`)
72+
}
73+
74+
fs.appendFileSync(filePath, `${toCommandValue(message)}${os.EOL}`, {
75+
encoding: 'utf8'
76+
})
77+
}
78+
79+
module.exports = { getInput, setOutput }

.github/actions/bump-version/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const action = require('./action');
2+
const fs = require('fs');
3+
const core = require('./core');
4+
5+
const version = fs.readFileSync(core.getInput('versionFile'), 'utf8');
6+
7+
const newVersion = action.bumpVersion(
8+
version,
9+
core.getInput('bumpType'),
10+
core.getInput('prereleaseSuffix')
11+
);
12+
13+
fs.writeFileSync(core.getInput('versionFile'), newVersion);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"verbose": true
3+
}

0 commit comments

Comments
 (0)