Skip to content

Commit 6ef5889

Browse files
committed
ci: automate releases
Publishing this app is manual today, and the repo drifted from what Zapier runs: #99 had to reconstruct 4.10.0 through 4.14.0 out of the published app and back into git. A bot now opens one release PR when code that reaches Zapier changed since the last release, and merging it uploads the version and promotes it. It reads the last release from the last commit that changed the version field rather than a tag, because tagging stopped here in 2021 and the version field is what Zapier keys on. Existing Zaps keep the version they were built on, because nothing migrates them. The repo moves to Node 22, because the pinned Zapier CLI pulls dependencies that refuse to install below Node 20. Zapier still runs this app on Node 18, and will until we move to core 17. Check the promote step. It passes --yes, without which the CLI waits on a prompt nobody can answer on a runner, and that flag also accepts Zapier's non-blocking pre-check warnings. Towards INF-3414
1 parent 9b0bdf7 commit 6ef5889

11 files changed

Lines changed: 5489 additions & 91 deletions

.github/workflows/nodejs.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ jobs:
88

99
strategy:
1010
matrix:
11-
node-version: [18.19.1]
11+
node-version: [22.23.2]
1212

1313
steps:
14-
- uses: actions/checkout@v1
14+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
15+
with:
16+
persist-credentials: false
1517
- name: Use Node.js ${{ matrix.node-version }}
16-
uses: actions/setup-node@v1
18+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
1719
with:
1820
node-version: ${{ matrix.node-version }}
1921
- name: yarn, build, and test

.github/workflows/release-pr.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: Release PR
2+
3+
on:
4+
push:
5+
branches: [master]
6+
7+
permissions:
8+
contents: write
9+
pull-requests: write
10+
11+
concurrency:
12+
group: release-pr
13+
cancel-in-progress: false
14+
15+
jobs:
16+
release-pr:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 15
19+
steps:
20+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21+
with:
22+
fetch-depth: 0
23+
persist-credentials: false
24+
25+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
26+
with:
27+
node-version-file: .nvmrc
28+
cache: yarn
29+
30+
- name: Look for changes that never shipped
31+
id: detect
32+
run: |
33+
last=$(git log -1 --format=%H -G'"version":' -- package.json)
34+
echo "last release commit: $last"
35+
36+
changed=$(git diff --name-only "$last..HEAD" -- src index.js tsconfig.json ':(exclude)src/test')
37+
git show "$last:package.json" > "$RUNNER_TEMP/old-package.json"
38+
deps=$(node scripts/dependencies-changed.js "$RUNNER_TEMP/old-package.json" package.json)
39+
40+
if [ -z "$changed" ] && [ "$deps" = "false" ]; then
41+
echo "nothing to release"
42+
echo "release=false" >> "$GITHUB_OUTPUT"
43+
exit 0
44+
fi
45+
echo "changed files:"
46+
echo "$changed"
47+
echo "dependencies changed: $deps"
48+
echo "release=true" >> "$GITHUB_OUTPUT"
49+
git log --first-parent --format=%s "$last..HEAD" > "$RUNNER_TEMP/titles.txt"
50+
51+
- name: Test before proposing a release
52+
if: steps.detect.outputs.release == 'true'
53+
run: |
54+
yarn install --frozen-lockfile
55+
yarn test
56+
env:
57+
CI: true
58+
NODE_ENV: test
59+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
60+
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
61+
62+
- name: Prepare the bump
63+
if: steps.detect.outputs.release == 'true'
64+
id: prepare
65+
run: |
66+
mapfile -t titles < "$RUNNER_TEMP/titles.txt"
67+
version=$(node scripts/prepare-release.js "${titles[@]}")
68+
echo "version=$version" >> "$GITHUB_OUTPUT"
69+
70+
- name: Open or update the release PR
71+
if: steps.detect.outputs.release == 'true'
72+
env:
73+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74+
VERSION: ${{ steps.prepare.outputs.version }}
75+
run: |
76+
git config user.name "github-actions[bot]"
77+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
78+
git checkout -B release/next
79+
git commit -m "chore: release $VERSION" package.json CHANGELOG.md
80+
git push --force "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY" release/next
81+
82+
if [ -z "$(gh pr list --head release/next --state open --json number --jq '.[].number')" ]; then
83+
gh pr create --base master --head release/next \
84+
--title "Release $VERSION" \
85+
--body "Merging this uploads $VERSION to Zapier and promotes it. This branch is rebuilt on every push to master, so edit the changelog entry just before you merge, or after."
86+
fi

.github/workflows/release.yml

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
branches: [master]
6+
paths: ["package.json"]
7+
8+
permissions:
9+
contents: read
10+
11+
concurrency:
12+
group: release-${{ github.sha }}
13+
cancel-in-progress: false
14+
15+
jobs:
16+
release:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 45
19+
steps:
20+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21+
with:
22+
fetch-depth: 0
23+
persist-credentials: false
24+
25+
- name: Look for a version change
26+
id: detect
27+
env:
28+
BEFORE: ${{ github.event.before }}
29+
run: |
30+
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
31+
echo "no previous commit to compare against"
32+
echo "release=false" >> "$GITHUB_OUTPUT"
33+
exit 0
34+
fi
35+
36+
current=$(node -p "require('./package.json').version")
37+
previous=$(git show "$BEFORE:package.json" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")
38+
if [ "$current" = "$previous" ]; then
39+
echo "version unchanged, nothing to do"
40+
echo "release=false" >> "$GITHUB_OUTPUT"
41+
exit 0
42+
fi
43+
echo "releasing $previous -> $current"
44+
echo "release=true" >> "$GITHUB_OUTPUT"
45+
echo "version=$current" >> "$GITHUB_OUTPUT"
46+
47+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
48+
if: steps.detect.outputs.release == 'true'
49+
with:
50+
node-version-file: .nvmrc
51+
cache: yarn
52+
53+
- name: Install
54+
if: steps.detect.outputs.release == 'true'
55+
run: yarn install --frozen-lockfile
56+
57+
- name: Test
58+
if: steps.detect.outputs.release == 'true'
59+
run: yarn test
60+
env:
61+
CI: true
62+
NODE_ENV: test
63+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
64+
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
65+
66+
- name: Build, validate and upload
67+
if: steps.detect.outputs.release == 'true'
68+
run: yarn zapier-push
69+
env:
70+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
71+
72+
- name: Promote
73+
if: steps.detect.outputs.release == 'true'
74+
env:
75+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
76+
VERSION: ${{ steps.detect.outputs.version }}
77+
run: |
78+
./node_modules/.bin/zapier-platform promote --yes "$VERSION"
79+
80+
for attempt in $(seq 1 60); do
81+
stage=$(./node_modules/.bin/zapier-platform jobs --format=raw 2>/dev/null \
82+
| jq -r --arg v "$VERSION" '[.[] | select(.job_kind == "promote" and .version_to == $v)] | last | .job_stage' 2>/dev/null || true)
83+
echo "attempt $attempt: ${stage:-no job yet}"
84+
case "$stage" in
85+
complete) exit 0 ;;
86+
errored|aborted|paused) echo "promotion ended as $stage" >&2; exit 1 ;;
87+
esac
88+
sleep 20
89+
done
90+
91+
echo "promotion did not finish within 20 minutes" >&2
92+
exit 1

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
18.19.1
1+
22.23.2

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@ For testing, save your envvars to `.env`. `.env.default` has the required variab
2222

2323
### Deployment
2424

25-
Prerequisites:
25+
Releases are automatic. A bot opens a release PR whenever code that ships has changed since the
26+
last release. Merging that PR uploads the version to Zapier and promotes it, so new Zaps get it.
2627

27-
- Make sure you have updated the version number in `package.json`.
28-
- If updating Linear's app, you'll need to have access to Linear's Zapier account and generate a deploy key in `Settings > Deploy Keys`. You can then authenticate with the key using `zapier login --sso`.
28+
Two things the release does not do. It does not move existing Zaps to the new version, so they
29+
keep running the version they were built on. It does not deprecate old versions.
2930

30-
You can deploy the app to Zapier with `yarn zapier-push`. This will also run `yarn zapier-validate` before deploying.
31-
32-
After deploying, you'll need to manually promote the version to 'public' in Zapier's dashboard under `App > Manage > Versions`.
31+
Edit the changelog entry in the release PR before you merge. The bot fills it from pull request
32+
titles, which is a starting point. The branch is rebuilt on every push to master, so make your
33+
edit just before you merge.
3334

3435
## Forking
3536

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
"zapier-push": "npm run zapier-validate && zapier-platform push",
1212
"prepare": "npm run zapier-build && (husky install || true)",
1313
"pretest": "npm run zapier-build",
14-
"test": "jest --passWithNoTests src/test",
14+
"test": "jest --passWithNoTests src/test scripts",
1515
"prettier": "npx prettier --write ."
1616
},
1717
"engines": {
18-
"node": ">=18.0.0",
18+
"node": ">=22.0.0",
1919
"npm": ">=10.0.0"
2020
},
2121
"dependencies": {
@@ -31,7 +31,8 @@
3131
"jest": "^29.7.0",
3232
"lint-staged": ">=10",
3333
"prettier": "^3.3.3",
34-
"typescript": "^5.3.3"
34+
"typescript": "^5.3.3",
35+
"zapier-platform-cli": "19.1.0"
3536
},
3637
"lint-staged": {
3738
"*.{js,ts,json}": "prettier --write"

scripts/dependencies-changed.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const fs = require("fs");
2+
3+
const canonical = (dependencies = {}) =>
4+
JSON.stringify(
5+
Object.keys(dependencies)
6+
.sort()
7+
.map((name) => [name, dependencies[name]])
8+
);
9+
10+
const dependenciesChanged = (oldManifest, newManifest) =>
11+
canonical(oldManifest.dependencies) !== canonical(newManifest.dependencies);
12+
13+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
14+
15+
module.exports = { dependenciesChanged };
16+
17+
if (require.main === module) {
18+
const [oldFile, newFile] = process.argv.slice(2);
19+
process.stdout.write(String(dependenciesChanged(read(oldFile), read(newFile))));
20+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const { dependenciesChanged } = require("./dependencies-changed");
2+
3+
const manifest = (dependencies) => ({ name: "linear-zapier", version: "4.15.0", dependencies });
4+
5+
describe("dependenciesChanged", () => {
6+
it("sees a version change", () => {
7+
expect(dependenciesChanged(manifest({ lodash: "4.17.21" }), manifest({ lodash: "4.17.22" }))).toBe(true);
8+
});
9+
10+
it("sees an added and a removed dependency", () => {
11+
expect(dependenciesChanged(manifest({ lodash: "4.17.21" }), manifest({ lodash: "4.17.21", ky: "1.0.0" }))).toBe(
12+
true
13+
);
14+
expect(dependenciesChanged(manifest({ lodash: "4.17.21", ky: "1.0.0" }), manifest({ lodash: "4.17.21" }))).toBe(
15+
true
16+
);
17+
});
18+
19+
it("ignores key order", () => {
20+
expect(
21+
dependenciesChanged(manifest({ lodash: "4.17.21", ky: "1.0.0" }), manifest({ ky: "1.0.0", lodash: "4.17.21" }))
22+
).toBe(false);
23+
});
24+
25+
it("ignores everything outside the dependencies block", () => {
26+
const before = { ...manifest({ lodash: "4.17.21" }), devDependencies: { jest: "29.7.0" }, scripts: { test: "a" } };
27+
const after = { ...manifest({ lodash: "4.17.21" }), devDependencies: { jest: "30.0.0" }, scripts: { test: "b" } };
28+
expect(dependenciesChanged(before, after)).toBe(false);
29+
});
30+
31+
it("treats a missing dependencies block as empty", () => {
32+
expect(dependenciesChanged({ name: "x" }, { name: "x" })).toBe(false);
33+
expect(dependenciesChanged({ name: "x" }, manifest({ lodash: "4.17.21" }))).toBe(true);
34+
});
35+
});

scripts/prepare-release.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const fs = require("fs");
2+
const path = require("path");
3+
4+
const ROOT = path.join(__dirname, "..");
5+
const PACKAGE_FILE = path.join(ROOT, "package.json");
6+
const CHANGELOG_FILE = path.join(ROOT, "CHANGELOG.md");
7+
8+
const nextMinor = (version) => {
9+
const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
10+
if (!parts) {
11+
throw new Error(`Unsupported version: ${version}`);
12+
}
13+
return `${parts[1]}.${Number(parts[2]) + 1}.0`;
14+
};
15+
16+
const stripPullRequestReference = (title) => title.replace(/\s*\(?#\d+\)?/g, "").trim();
17+
18+
const changelogEntry = (version, titles) => {
19+
const bullets = titles.length > 0 ? titles.map(stripPullRequestReference) : ["No description."];
20+
return `## ${version}\n\n${bullets.map((title) => `- ${title}`).join("\n")}\n`;
21+
};
22+
23+
const prependEntry = (changelog, entry) => `${entry}\n${changelog}`;
24+
25+
const main = (titles) => {
26+
const manifest = JSON.parse(fs.readFileSync(PACKAGE_FILE, "utf8"));
27+
const version = nextMinor(manifest.version);
28+
29+
manifest.version = version;
30+
fs.writeFileSync(PACKAGE_FILE, `${JSON.stringify(manifest, null, 2)}\n`);
31+
32+
const changelog = fs.readFileSync(CHANGELOG_FILE, "utf8");
33+
fs.writeFileSync(CHANGELOG_FILE, prependEntry(changelog, changelogEntry(version, titles)));
34+
35+
process.stdout.write(version);
36+
};
37+
38+
module.exports = { nextMinor, changelogEntry, prependEntry };
39+
40+
if (require.main === module) {
41+
main(process.argv.slice(2));
42+
}

scripts/prepare-release.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const { nextMinor, changelogEntry, prependEntry } = require("./prepare-release");
2+
3+
describe("nextMinor", () => {
4+
it("raises the minor and resets the patch", () => {
5+
expect(nextMinor("4.15.0")).toBe("4.16.0");
6+
expect(nextMinor("4.15.3")).toBe("4.16.0");
7+
expect(nextMinor("4.9.0")).toBe("4.10.0");
8+
});
9+
10+
it("rejects a version it does not understand", () => {
11+
expect(() => nextMinor("4.15")).toThrow("Unsupported version");
12+
expect(() => nextMinor("v4.15.0")).toThrow("Unsupported version");
13+
});
14+
});
15+
16+
describe("changelogEntry", () => {
17+
it("writes one bullet per title", () => {
18+
expect(changelogEntry("4.16.0", ["Add a search", "Fix a trigger"])).toBe(
19+
"## 4.16.0\n\n- Add a search\n- Fix a trigger\n"
20+
);
21+
});
22+
23+
it("stays valid when there are no titles", () => {
24+
expect(changelogEntry("4.16.0", [])).toBe("## 4.16.0\n\n- No description.\n");
25+
});
26+
27+
it("strips a trailing parenthesized reference", () => {
28+
expect(changelogEntry("4.16.0", ["Add a search (#101)"])).toBe("## 4.16.0\n\n- Add a search\n");
29+
});
30+
31+
it("strips a mid-sentence bare reference", () => {
32+
expect(changelogEntry("4.16.0", ["Fix #101 not being closed on merge"])).toBe(
33+
"## 4.16.0\n\n- Fix not being closed on merge\n"
34+
);
35+
});
36+
37+
it("strips a mid-sentence parenthesized reference", () => {
38+
expect(changelogEntry("4.16.0", ["Add (#1) mid-title text"])).toBe("## 4.16.0\n\n- Add mid-title text\n");
39+
});
40+
41+
it("passes through a subject with no reference", () => {
42+
expect(changelogEntry("4.16.0", ["Add a new feature"])).toBe("## 4.16.0\n\n- Add a new feature\n");
43+
});
44+
});
45+
46+
describe("prependEntry", () => {
47+
it("puts the new entry above the previous one", () => {
48+
expect(prependEntry("## 4.15.0\n\n- Older.\n", "## 4.16.0\n\n- Newer.\n")).toBe(
49+
"## 4.16.0\n\n- Newer.\n\n## 4.15.0\n\n- Older.\n"
50+
);
51+
});
52+
});

0 commit comments

Comments
 (0)