Skip to content

Commit 4d773cb

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. A scheduled check compares the version in git with what Zapier holds, because the bot reads what shipped out of the version field, and that records intent rather than outcome. 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 4d773cb

14 files changed

Lines changed: 5623 additions & 90 deletions

.github/workflows/nodejs.yml

Lines changed: 9 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
@@ -25,3 +27,7 @@ jobs:
2527
NODE_ENV: test
2628
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
2729
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
30+
- name: Validate the Zapier schema
31+
run: |
32+
yarn zapier-build
33+
./node_modules/.bin/zapier-platform validate --without-style
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Release drift
2+
3+
on:
4+
schedule:
5+
- cron: "0 9 * * 1-5"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
drift:
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 10
15+
steps:
16+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
17+
with:
18+
persist-credentials: false
19+
20+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
21+
with:
22+
node-version-file: .nvmrc
23+
cache: yarn
24+
25+
- name: Install
26+
run: yarn install --frozen-lockfile
27+
28+
- name: Compare git with Zapier
29+
env:
30+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
31+
run: |
32+
./node_modules/.bin/zapier-platform versions --format=raw > "$RUNNER_TEMP/versions.json"
33+
./node_modules/.bin/zapier-platform jobs --format=raw > "$RUNNER_TEMP/jobs.json"
34+
jq -e . "$RUNNER_TEMP/jobs.json" >/dev/null 2>&1 || echo "[]" > "$RUNNER_TEMP/jobs.json"
35+
node scripts/check-release-drift.js "$RUNNER_TEMP/versions.json" "$RUNNER_TEMP/jobs.json"

.github/workflows/release-pr.yml

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

.github/workflows/release.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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
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+
if ! printf '%s' "$current" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
38+
echo "unexpected version format: $current" >&2
39+
exit 1
40+
fi
41+
previous=$(git show "$BEFORE:package.json" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")
42+
if [ "$current" = "$previous" ]; then
43+
echo "version unchanged, nothing to do"
44+
echo "release=false" >> "$GITHUB_OUTPUT"
45+
exit 0
46+
fi
47+
echo "releasing $previous -> $current"
48+
echo "release=true" >> "$GITHUB_OUTPUT"
49+
echo "version=$current" >> "$GITHUB_OUTPUT"
50+
51+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
52+
if: steps.detect.outputs.release == 'true'
53+
with:
54+
node-version-file: .nvmrc
55+
cache: yarn
56+
57+
- name: Install
58+
if: steps.detect.outputs.release == 'true'
59+
run: yarn install --frozen-lockfile
60+
61+
- name: Test
62+
if: steps.detect.outputs.release == 'true'
63+
run: yarn test
64+
env:
65+
CI: true
66+
NODE_ENV: test
67+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
68+
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
69+
70+
- name: Build, validate and upload
71+
if: steps.detect.outputs.release == 'true'
72+
run: yarn zapier-push
73+
env:
74+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
75+
76+
- name: Promote
77+
if: steps.detect.outputs.release == 'true'
78+
env:
79+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
80+
VERSION: ${{ steps.detect.outputs.version }}
81+
run: |
82+
./node_modules/.bin/zapier-platform promote --yes "$VERSION"
83+
84+
for attempt in $(seq 1 60); do
85+
stage=$(./node_modules/.bin/zapier-platform jobs --format=raw 2>/dev/null \
86+
| jq -r --arg v "$VERSION" '[.[] | select(.job_kind == "promote" and .version_to == $v)] | last | .job_stage' 2>/dev/null || true)
87+
echo "attempt $attempt: ${stage:-no job yet}"
88+
case "$stage" in
89+
complete) exit 0 ;;
90+
errored|aborted|paused) echo "promotion ended as $stage" >&2; exit 1 ;;
91+
esac
92+
sleep 20
93+
done
94+
95+
echo "promotion did not finish within 20 minutes" >&2
96+
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: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,22 @@ 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+
Edit the changelog entry in the release PR if the generated one reads badly. The bot fills it from
32+
pull request titles, which is a starting point. Once you commit to that branch the bot leaves it
33+
alone, so it stops adding later changes from master.
3134

32-
After deploying, you'll need to manually promote the version to 'public' in Zapier's dashboard under `App > Manage > Versions`.
35+
If a release run fails, fix the cause and re-run it from the Actions tab. It repeats the whole
36+
release. If the upload already succeeded and only the promotion failed, promote that version from
37+
the Zapier dashboard instead, because Zapier rejects a second upload of a version it already has.
38+
39+
A scheduled check compares the version in `package.json` with what Zapier holds, and fails if the
40+
version in git never reached Zapier. Run it from the Actions tab when you want an answer sooner.
3341

3442
## Forking
3543

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/check-release-drift.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 END_STAGES = ["aborted", "errored", "paused"];
7+
8+
const driftReason = (version, versions, jobs) => {
9+
const row = versions.find((v) => v.version === version);
10+
if (!row) {
11+
return `${version} is in git but was never uploaded to Zapier.`;
12+
}
13+
if (row.state === "deprecated") {
14+
return `${version} is deprecated on Zapier.`;
15+
}
16+
17+
const promotions = jobs.filter((job) => job.job_kind === "promote" && job.version_to === version);
18+
const completed = promotions.some((job) => job.job_stage === "complete");
19+
const failed = promotions.find((job) => END_STAGES.includes(job.job_stage));
20+
if (failed && !completed) {
21+
return `${version} promotion ended as ${failed.job_stage}.`;
22+
}
23+
24+
return null;
25+
};
26+
27+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
28+
29+
module.exports = { driftReason };
30+
31+
if (require.main === module) {
32+
const [versionsFile, jobsFile] = process.argv.slice(2);
33+
const manifest = read(PACKAGE_FILE);
34+
const versions = read(versionsFile);
35+
const jobs = read(jobsFile);
36+
37+
const reason = driftReason(manifest.version, versions, jobs);
38+
if (reason) {
39+
process.stdout.write(`${reason}\n`);
40+
process.exit(1);
41+
}
42+
process.stdout.write(`${manifest.version} matches what Zapier holds.\n`);
43+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const { driftReason } = require("./check-release-drift");
2+
3+
describe("driftReason", () => {
4+
it("sees no drift when the version shipped and no jobs exist", () => {
5+
expect(driftReason("4.16.0", [{ version: "4.16.0", state: "live" }], [])).toBe(null);
6+
});
7+
8+
it("flags a version missing from Zapier", () => {
9+
expect(driftReason("4.16.0", [{ version: "4.15.0", state: "live" }], [])).toMatch(/never uploaded/);
10+
});
11+
12+
it("flags a deprecated version", () => {
13+
expect(driftReason("4.16.0", [{ version: "4.16.0", state: "deprecated" }], [])).toMatch(/deprecated/);
14+
});
15+
16+
it("flags a version whose only promotion errored", () => {
17+
const jobs = [{ job_kind: "promote", version_to: "4.16.0", job_stage: "errored" }];
18+
expect(driftReason("4.16.0", [{ version: "4.16.0", state: "live" }], jobs)).toMatch(/errored/);
19+
});
20+
21+
it("sees no drift when a later promotion completed", () => {
22+
const jobs = [
23+
{ job_kind: "promote", version_to: "4.16.0", job_stage: "errored" },
24+
{ job_kind: "promote", version_to: "4.16.0", job_stage: "complete" },
25+
];
26+
expect(driftReason("4.16.0", [{ version: "4.16.0", state: "live" }], jobs)).toBe(null);
27+
});
28+
});

0 commit comments

Comments
 (0)