@@ -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
2730on :
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