Skip to content

Commit 31cda39

Browse files
fix(ci): address review findings on cut-prerelease (#3253)
Non-blocking review notes from #3253: - Require write access to dispatch. workflow_dispatch already needs write access to trigger, but add an explicit getCollaboratorPermissionLevel gate (admin/maintain/write) as auditable defense-in-depth that fails closed. - Constrain a `main` dispatch to a new minor's pre-release (patch Z == 0). A patch-line pre-release built from main content would produce a wrong-tree release-X.Y.Z-rc.N staging branch; it must be cut from its release-X.Y line. - Guard against a stale checkout tip. workflow_dispatch pins the dispatch-time SHA, not the push-time branch tip (the gap includes the concurrency-queue wait); if the branch advanced, the push would tag a stale commit and yield an empty base_ref, failing tags.yaml after burning the write-once name. Compare local HEAD to the remote branch tip and fail with a re-run hint instead. - Distinguish ls-remote "tag absent" (exit 2) from a transport/auth failure (any other non-zero), so an infra blip cannot read as "free to create". - Shallow checkout (fetch-depth: 1, no fetch-tags) instead of a full-history clone to push one ref; drop the redundant local tag pre-check. - Run pure-input validation (tag + dispatch branch) before minting the token or checking out, so a bad dispatch fails in seconds. actionlint + zizmor clean; run blocks bash -n checked. Co-Authored-By: Claude Opus 4.8 <[email protected]> Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent bece285 commit 31cda39

1 file changed

Lines changed: 104 additions & 44 deletions

File tree

‎.github/workflows/cut-prerelease.yaml‎

Lines changed: 104 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ name: Cut Pre-release Tag
2020
# 2. It pushes `HEAD:refs/tags/<tag>` from the checked-out branch tip, so the
2121
# push event's base_ref is populated — tags.yaml's "Get base branch" step
2222
# needs it to detect main vs release-X.Y.
23-
# 3. Once every pre-release is cut here, repo admins can add a "Restrict
24-
# creations" tag ruleset on v* with this app as the sole bypass actor,
25-
# turning "don't hand-push tags" from convention into hard enforcement.
23+
# 3. It makes CI the sole creator of pre-release tags. Repo rulesets can
24+
# already restrict *stable* tag creation to CI (a "Restrict creations"
25+
# ruleset targeting v* while excluding v*-*), but pre-releases were
26+
# hand-pushed, so they could not be CI-locked. With this workflow as their
27+
# only entry point, admins can extend "Restrict creations" (CI app the sole
28+
# bypass) to *all* of v* — pre-release and stable alike.
2629

2730
on:
2831
workflow_dispatch:
@@ -50,19 +53,12 @@ jobs:
5053
timeout-minutes: 10
5154

5255
steps:
53-
- name: Generate GitHub App token
54-
id: app-token
55-
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
56-
with:
57-
app-id: ${{ secrets.COZYSTACK_CI_APP_ID }}
58-
private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }}
59-
owner: cozystack
60-
# Scope the token to this repo only — it never touches other repos.
61-
repositories: ${{ github.event.repository.name }}
56+
# Pure-input validation first — no token minted, no checkout, so a bad tag
57+
# or wrong dispatch branch fails in seconds.
6258

63-
# Validate the dispatch input against a strict pre-release pattern before
64-
# it reaches git. A stable vX.Y.Z is rejected on purpose: stable tags are
65-
# cut only by the promote flow, at a PR merge commit.
59+
# Validate the dispatch input against a strict pre-release pattern. A
60+
# stable vX.Y.Z is rejected on purpose: stable tags are cut only by the
61+
# promote flow, at a PR merge commit.
6662
- name: Parse and validate tag
6763
id: parse
6864
env:
@@ -81,31 +77,32 @@ jobs:
8177
}
8278
core.setOutput('tag', tag);
8379
core.setOutput('line', `${m[1]}.${m[2]}`); // 1.6
80+
core.setOutput('patch', m[3]); // 0
8481
85-
- name: Check out the dispatched branch
86-
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
87-
with:
88-
fetch-depth: 0
89-
fetch-tags: true
90-
# Authenticate origin with the app token so the tag push below is
91-
# attributed to the app (and thus triggers tags.yaml).
92-
token: ${{ steps.app-token.outputs.token }}
93-
94-
# Refuse to cut from anything but main or a release-X.Y line, and make the
95-
# tag's version line agree with a release-X.Y dispatch branch. The pushed
96-
# tag's base_ref (= the dispatch branch) must be one tags.yaml accepts.
82+
# Refuse to cut from anything but main or the *matching* release-X.Y line.
83+
# The pushed tag's base_ref (= the dispatch branch) must be one tags.yaml
84+
# accepts, and the branch must actually contain the version being cut.
9785
- name: Validate dispatch branch
9886
env:
9987
REF_NAME: ${{ github.ref_name }}
10088
REF_TYPE: ${{ github.ref_type }}
10189
LINE: ${{ steps.parse.outputs.line }}
90+
PATCH: ${{ steps.parse.outputs.patch }}
10291
run: |
10392
if [ "$REF_TYPE" != "branch" ]; then
10493
echo "::error::Dispatch this workflow from a branch (main or release-X.Y), not a tag or SHA."
10594
exit 1
10695
fi
10796
case "$REF_NAME" in
10897
main)
98+
# From main only a *new minor's* pre-release (vX.Y.0-*) is valid.
99+
# A patch-line pre-release (Z>0) built from main content would land
100+
# on a wrong-tree release-X.Y.Z-rc.N staging branch — it must be cut
101+
# from its own release-X.Y line instead.
102+
if [ "$PATCH" != "0" ]; then
103+
echo "::error::From 'main' only vX.Y.0-* pre-releases may be cut (got patch .$PATCH). Cut v$LINE.$PATCH-* from 'release-$LINE'."
104+
exit 1
105+
fi
109106
;;
110107
release-*)
111108
if [ "release-$LINE" != "$REF_NAME" ]; then
@@ -119,30 +116,93 @@ jobs:
119116
;;
120117
esac
121118
122-
# Pre-release tags are write-once. Fail early with a clear message rather
123-
# than letting the (non-forced) push reject cryptically.
124-
- name: Refuse to move an existing tag
119+
- name: Generate GitHub App token
120+
id: app-token
121+
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
122+
with:
123+
app-id: ${{ secrets.COZYSTACK_CI_APP_ID }}
124+
private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }}
125+
owner: cozystack
126+
# Scope the token to this repo only — it never touches other repos.
127+
repositories: ${{ github.event.repository.name }}
128+
129+
# Cutting a release is privileged. workflow_dispatch already requires write
130+
# access to trigger, so this is defense-in-depth: it makes the requirement
131+
# explicit and auditable, and fails closed if that platform default ever
132+
# changes or the job is reached by another path. Reads permission via the
133+
# app token (fails the step if the API errors — fail closed).
134+
- name: Require write access
135+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
136+
with:
137+
github-token: ${{ steps.app-token.outputs.token }}
138+
script: |
139+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
140+
owner: context.repo.owner,
141+
repo: context.repo.repo,
142+
username: context.actor,
143+
});
144+
const allowed = ['admin', 'maintain', 'write'];
145+
if (!allowed.includes(data.permission)) {
146+
core.setFailed(
147+
`@${context.actor} has '${data.permission}' access — cutting a pre-release tag ` +
148+
`requires write access to ${context.repo.owner}/${context.repo.repo}.`
149+
);
150+
return;
151+
}
152+
core.info(`@${context.actor}: '${data.permission}' access — authorized.`);
153+
154+
# Minimal checkout — just enough to configure origin with the app-token
155+
# credential (persist-credentials, default on) so the bare push below is
156+
# app-authenticated, and to resolve HEAD. One ref, shallow, no tags.
157+
- name: Check out the dispatched branch
158+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
159+
with:
160+
fetch-depth: 1
161+
token: ${{ steps.app-token.outputs.token }}
162+
163+
- name: Cut and push the pre-release tag
125164
env:
126165
TAG: ${{ steps.parse.outputs.tag }}
166+
REF_NAME: ${{ github.ref_name }}
127167
run: |
128-
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
129-
echo "::error::Tag $TAG already exists locally — pre-release tags are write-once."
168+
# Write-once: the tag must not already exist on origin. Distinguish
169+
# "absent" (git ls-remote --exit-code exits 2) from a transport/auth
170+
# failure (any other non-zero) so an infra blip cannot masquerade as
171+
# "free to create".
172+
set +e
173+
git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1
174+
rc=$?
175+
set -e
176+
if [ "$rc" -eq 0 ]; then
177+
echo "::error::Tag $TAG already exists on origin — pre-release tags are write-once."
178+
exit 1
179+
elif [ "$rc" -ne 2 ]; then
180+
echo "::error::Could not query origin for $TAG (git ls-remote exit $rc) — refusing to proceed."
130181
exit 1
131182
fi
132-
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
133-
echo "::error::Tag $TAG already exists on origin — pre-release tags are write-once."
183+
184+
# Stale-tip guard: workflow_dispatch checks out the dispatch-time commit,
185+
# not the branch tip at push time (the gap includes any wait in the
186+
# cut-prerelease concurrency queue). If the branch advanced meanwhile,
187+
# pushing HEAD would tag a stale commit and yield an empty base_ref —
188+
# tags.yaml would then fail, having already burned this write-once name.
189+
# Refuse and ask for a re-run instead.
190+
local_head="$(git rev-parse HEAD)"
191+
remote_head="$(git ls-remote origin "refs/heads/$REF_NAME" | cut -f1)"
192+
if [ -z "$remote_head" ]; then
193+
echo "::error::Could not resolve the tip of '$REF_NAME' on origin."
194+
exit 1
195+
fi
196+
if [ "$local_head" != "$remote_head" ]; then
197+
echo "::error::'$REF_NAME' advanced since dispatch ($local_head -> $remote_head). Re-run the workflow to tag the current tip."
134198
exit 1
135199
fi
136200
137-
# Push HEAD (the dispatch branch tip) straight to a tag ref — the exact
138-
# form docs/release.md prescribes for a manual cut, so GitHub populates the
139-
# push event's base_ref. No local tag object, no -f: a lightweight,
140-
# branch-anchored, write-once tag. tags.yaml takes it from here (build +
141-
# e2e + draft rc release + staging branch).
142-
- name: Cut and push the pre-release tag
143-
env:
144-
TAG: ${{ steps.parse.outputs.tag }}
145-
run: |
201+
# Push HEAD straight to a tag ref: no local tag object, non-forced. The
202+
# branch-tip anchoring makes GitHub populate the push event's base_ref
203+
# (tags.yaml's Get-base-branch step requires it); non-forced makes the
204+
# tag write-once at the git layer. tags.yaml takes it from here (build +
205+
# e2e + draft pre-release + staging branch).
146206
git push origin "HEAD:refs/tags/$TAG"
147-
echo "Pushed $TAG at $(git rev-parse HEAD) from '$GITHUB_REF_NAME'."
207+
echo "Pushed $TAG at $local_head from '$REF_NAME'."
148208
echo "tags.yaml will now build, e2e, and draft the pre-release."

0 commit comments

Comments
 (0)