Skip to content

Commit bef0e32

Browse files
committed
ci: harden release workflow
1 parent 564a962 commit bef0e32

2 files changed

Lines changed: 118 additions & 31 deletions

File tree

.github/workflows/release.yml

Lines changed: 110 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
description: >-
88
auto — default release-please behavior (publish when a release PR was merged).
99
release-pr — open or refresh the release PR only; merge it yourself to publish.
10-
force — bump version from current main and publish (for dependency-only changes).
10+
force — open a Release-As marker PR for dependency-only changes.
1111
required: true
1212
default: auto
1313
type: choice
@@ -61,29 +61,77 @@ jobs:
6161
echo "value=auto" >> "$GITHUB_OUTPUT"
6262
fi
6363
64-
- name: Prepare forced release commit
64+
- name: Resolve forced release version
6565
if: ${{ steps.mode.outputs.value == 'force' }}
6666
id: force
6767
env:
6868
BUMP: ${{ inputs.bump || 'patch' }}
6969
run: |
70-
CURRENT=$(jq -r '."packages/grove-cli"' .release-please-manifest.json)
71-
IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT"
72-
case "$BUMP" in
73-
major) NEXT="$((MAJOR + 1)).0.0" ;;
74-
minor) NEXT="${MAJOR}.$((MINOR + 1)).0" ;;
75-
patch) NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
76-
*) echo "Unsupported bump: $BUMP" >&2; exit 1 ;;
77-
esac
70+
NEXT=$(node --input-type=module <<'NODE'
71+
import { readFileSync } from "node:fs";
72+
73+
const bump = process.env.BUMP;
74+
const manifest = JSON.parse(readFileSync(".release-please-manifest.json", "utf8"));
75+
const versions = Object.values(manifest).map((version) => {
76+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
77+
if (!match) throw new Error(`Unsupported manifest version: ${version}`);
78+
return match.slice(1).map(Number);
79+
});
80+
const [major, minor, patch] = versions.reduce((max, version) => {
81+
for (let i = 0; i < 3; i += 1) {
82+
if (version[i] > max[i]) return version;
83+
if (version[i] < max[i]) return max;
84+
}
85+
return max;
86+
});
87+
88+
if (bump === "major") console.log(`${major + 1}.0.0`);
89+
else if (bump === "minor") console.log(`${major}.${minor + 1}.0`);
90+
else if (bump === "patch") console.log(`${major}.${minor}.${patch + 1}`);
91+
else throw new Error(`Unsupported bump: ${bump}`);
92+
NODE
93+
)
7894
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
95+
echo "Resolved forced release version $NEXT"
96+
97+
- name: Open forced release marker PR
98+
if: ${{ steps.mode.outputs.value == 'force' }}
99+
env:
100+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
101+
NEXT: ${{ steps.force.outputs.version }}
102+
run: |
103+
set -euo pipefail
104+
BRANCH="release-force/${NEXT}"
105+
79106
git config user.name "github-actions[bot]"
80107
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
81-
git commit --allow-empty -m "chore: force release ${NEXT}" -m "Release-As: ${NEXT}"
82-
git push origin HEAD:main
83-
echo "Prepared Release-As $NEXT"
108+
109+
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
110+
git fetch origin "$BRANCH"
111+
git checkout -B "$BRANCH" "origin/$BRANCH"
112+
else
113+
git checkout -B "$BRANCH"
114+
git commit --allow-empty -m "chore: force release ${NEXT}" -m "Release-As: ${NEXT}"
115+
git push --set-upstream origin "$BRANCH"
116+
fi
117+
118+
BODY=$(cat <<EOF
119+
Opens a protected-branch compliant Release-As marker.
120+
121+
After this PR is merged, the normal push workflow will open the release-please PR for ${NEXT}. Merge that release PR to publish to npm.
122+
EOF
123+
)
124+
125+
PR=$(gh pr list --head "$BRANCH" --base main --json number --jq '.[0].number')
126+
if [ -z "$PR" ] || [ "$PR" = "null" ]; then
127+
gh pr create --base main --head "$BRANCH" --title "chore: force release ${NEXT}" --body "$BODY"
128+
else
129+
gh pr edit "$PR" --title "chore: force release ${NEXT}" --body "$BODY"
130+
echo "Updated existing force release PR #$PR"
131+
fi
84132
85133
- name: Open or refresh release PR
86-
if: ${{ steps.mode.outputs.value == 'force' || steps.mode.outputs.value == 'release-pr' }}
134+
if: ${{ steps.mode.outputs.value == 'release-pr' }}
87135
uses: googleapis/release-please-action@v5
88136
id: release_pr
89137
with:
@@ -92,28 +140,14 @@ jobs:
92140
manifest-file: .release-please-manifest.json
93141
skip-github-release: true
94142

95-
- name: Merge release PR
96-
if: ${{ steps.mode.outputs.value == 'force' }}
97-
env:
98-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
99-
run: |
100-
PR=$(gh pr list --head release-please--branches--main --base main --json number --jq '.[0].number')
101-
if [ -z "$PR" ] || [ "$PR" = "null" ]; then
102-
echo "No release PR found to merge" >&2
103-
exit 1
104-
fi
105-
gh pr merge "$PR" --merge --delete-branch
106-
git pull origin main
107-
108143
- name: Release Please
109-
if: ${{ steps.mode.outputs.value != 'release-pr' }}
144+
if: ${{ steps.mode.outputs.value == 'auto' }}
110145
uses: googleapis/release-please-action@v5
111146
id: release
112147
with:
113148
token: ${{ secrets.GITHUB_TOKEN }}
114149
config-file: release-please-config.json
115150
manifest-file: .release-please-manifest.json
116-
skip-github-pull-request: ${{ steps.mode.outputs.value == 'force' }}
117151

118152
- name: Install Dependencies
119153
if: ${{ steps.release.outputs.releases_created == 'true' }}
@@ -129,6 +163,52 @@ jobs:
129163

130164
- name: Publish to NPM
131165
if: ${{ steps.release.outputs.releases_created == 'true' }}
132-
run: pnpm -r publish --access public
133166
env:
134167
NPM_CONFIG_PROVENANCE: true
168+
PATHS_RELEASED: ${{ steps.release.outputs.paths_released }}
169+
run: |
170+
set -euo pipefail
171+
node --input-type=module <<'NODE' > /tmp/released-packages
172+
const paths = JSON.parse(process.env.PATHS_RELEASED || "[]");
173+
for (const path of paths) console.log(path);
174+
NODE
175+
176+
if [ ! -s /tmp/released-packages ]; then
177+
echo "No released package paths reported by release-please" >&2
178+
exit 1
179+
fi
180+
181+
while IFS= read -r path; do
182+
echo "Publishing ${path}"
183+
pnpm --filter "./${path}" publish --access public --no-git-checks
184+
done < /tmp/released-packages
185+
186+
- name: Verify NPM dist-tags
187+
if: ${{ steps.release.outputs.releases_created == 'true' }}
188+
env:
189+
PATHS_RELEASED: ${{ steps.release.outputs.paths_released }}
190+
run: |
191+
node --input-type=module <<'NODE'
192+
import { execFileSync } from "node:child_process";
193+
import { readFileSync } from "node:fs";
194+
import { join } from "node:path";
195+
196+
const paths = JSON.parse(process.env.PATHS_RELEASED || "[]");
197+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
198+
199+
for (const path of paths) {
200+
const pkg = JSON.parse(readFileSync(join(path, "package.json"), "utf8"));
201+
let latest = "";
202+
for (let attempt = 1; attempt <= 6; attempt += 1) {
203+
latest = execFileSync("npm", ["view", `${pkg.name}@latest`, "version"], {
204+
encoding: "utf8",
205+
}).trim();
206+
if (latest === pkg.version) break;
207+
await sleep(10000);
208+
}
209+
if (latest !== pkg.version) {
210+
throw new Error(`${pkg.name}@latest is ${latest}, expected ${pkg.version}`);
211+
}
212+
console.log(`${pkg.name}@latest -> ${latest}`);
213+
}
214+
NODE

release-please-config.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,12 @@
2222
"packages/grove-cli": {
2323
"release-type": "node"
2424
}
25-
}
25+
},
26+
"plugins": [
27+
{
28+
"type": "linked-versions",
29+
"groupName": "grove",
30+
"components": ["grove", "grove-cli"]
31+
}
32+
]
2633
}

0 commit comments

Comments
 (0)