22import fs from "fs" ;
33import path from "path" ;
44
5- // --------------------------
6- // GitHub Actions entrypoint
7- // --------------------------
8- /** @param {import('@actions/github-script').AsyncFunctionArguments } args */
9- export default async function publishFinalRelease ( { github, context, core } ) {
10- const {
11- FINAL_TAG : finalTag ,
12- FINAL_VERSION : finalVersion ,
13- RC_TAG : rcTag ,
14- IMAGE_REPO : imageRepo ,
15- CHART_REPO : chartRepo ,
16- IMAGE_DIGEST : imageDigest ,
17- CHART_DIR : chartDir ,
18- NOTES_FILE : notesFile ,
19- SET_LATEST : setLatest ,
20- HIGHEST_FINAL_VERSION : highestFinalVersion ,
21- } = process . env ;
22-
23- if ( ! finalTag || ! finalVersion || ! rcTag || ! chartDir || ! notesFile ) {
24- core . setFailed ( "Missing required environment variables" ) ;
25- return ;
26- }
27-
28- const isLatest = setLatest === "true" ;
29- const notes = prepareReleaseNotes ( notesFile , rcTag , finalTag ) ;
30- const release = await getOrCreateRelease ( github , context , {
31- finalTag,
32- finalVersion,
33- notes,
34- isLatest,
35- } ) ;
36- await uploadChartAssets ( github , context , core , release . id , chartDir ) ;
37- await writeSummary ( core , {
38- finalTag,
39- rcTag,
40- finalVersion,
41- imageRepo,
42- chartRepo,
43- imageDigest,
44- isLatest,
45- highestFinalVersion,
46- releaseUrl : release . html_url ,
47- } ) ;
48- }
49-
505// --------------------------
516// Helpers
527// --------------------------
538
549/**
55- * Read the RC changelog and rewrite the header for the final release.
56- * Falls back to a simple "Promoted from …" message if the file is missing.
10+ * Promote changelog from RC: Read RC changelog and rewrite header for the final release.
11+ * Falls back to a simple "Promoted from …" message if file is missing.
12+ *
13+ * The header pattern is derived dynamically from the RC tag, so it works for
14+ * any component prefix (cli/v…, kubernetes/controller/v…, etc.).
5715 *
5816 * @param {string } notesFile - Path to the changelog markdown file.
5917 * @param {string } rcTag - The RC tag being promoted (e.g. "kubernetes/controller/v0.1.0-rc.1").
@@ -73,9 +31,22 @@ export function prepareReleaseNotes(notesFile, rcTag, finalTag) {
7331 }
7432
7533 const today = new Date ( ) . toISOString ( ) . split ( "T" ) [ 0 ] ;
34+
35+ // Build a regex that matches the RC header line produced by git-cliff.
36+ // Example header: "## [kubernetes/controller/v0.1.0-rc.1] - 2026-03-08"
37+ // We escape the RC tag for safe regex usage, then match the full line.
38+ const escapedRcTag = rcTag . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
39+ const rcHeaderPattern = new RegExp ( `^## \\[${ escapedRcTag } \\].*$` , "m" ) ;
40+
41+ if ( ! rcHeaderPattern . test ( notes ) ) {
42+ // If no RC header found, prepend a final header instead of failing.
43+ // This handles edge cases like manually edited release notes.
44+ return `## [${ finalTag } ] - promoted from [${ rcTag } ] on ${ today } \n\n${ notes } ` ;
45+ }
46+
7647 return notes . replace (
77- / ^ \[ ( [ ^ \] ] + ) \] \s * - \s * [ \d - ] + / ,
78- `[${ finalTag } ] - promoted from [${ rcTag } ] on ${ today } ` ,
48+ rcHeaderPattern ,
49+ `## [${ finalTag } ] - promoted from [${ rcTag } ] on ${ today } ` ,
7950 ) ;
8051}
8152
@@ -87,14 +58,16 @@ export function prepareReleaseNotes(notesFile, rcTag, finalTag) {
8758 * @param {object } opts
8859 * @param {string } opts.finalTag
8960 * @param {string } opts.finalVersion
61+ * @param {string } opts.componentName
9062 * @param {string } opts.notes
9163 * @param {boolean } opts.isLatest
9264 * @returns {Promise<{id: number, html_url: string}> }
9365 */
9466export async function getOrCreateRelease ( github , context , opts ) {
95- const { finalTag, finalVersion, notes, isLatest } = opts ;
67+ const { finalTag, finalVersion, componentName , notes, isLatest } = opts ;
9668 const repo = { owner : context . repo . owner , repo : context . repo . repo } ;
9769 const makeLatest = isLatest ? "true" : "false" ;
70+ const releaseName = `${ componentName } ${ finalVersion } ` ;
9871
9972 try {
10073 const existing = await github . rest . repos . getReleaseByTag ( {
@@ -105,7 +78,7 @@ export async function getOrCreateRelease(github, context, opts) {
10578 ...repo ,
10679 release_id : existing . data . id ,
10780 tag_name : finalTag ,
108- name : `Controller ${ finalVersion } ` ,
81+ name : releaseName ,
10982 body : notes ,
11083 prerelease : false ,
11184 make_latest : makeLatest ,
@@ -116,7 +89,7 @@ export async function getOrCreateRelease(github, context, opts) {
11689 const created = await github . rest . repos . createRelease ( {
11790 ...repo ,
11891 tag_name : finalTag ,
119- name : `Controller ${ finalVersion } ` ,
92+ name : releaseName ,
12093 body : notes ,
12194 prerelease : false ,
12295 make_latest : makeLatest ,
@@ -126,15 +99,16 @@ export async function getOrCreateRelease(github, context, opts) {
12699}
127100
128101/**
129- * Upload .tgz chart files as release assets, replacing duplicates.
102+ * Upload all files from assets directory as release assets, replacing duplicates.
130103 *
131104 * @param {object } github - Octokit instance.
132105 * @param {object } context - GitHub Actions context.
133106 * @param {object } core - GitHub Actions core module.
134107 * @param {number } releaseId - The release to attach assets to.
135- * @param {string } chartDir - Directory containing chart .tgz files.
108+ * @param {string } assetsDir - Directory containing files to upload.
109+ * @returns {Promise<number> } Number of uploaded files.
136110 */
137- export async function uploadChartAssets ( github , context , core , releaseId , chartDir ) {
111+ export async function uploadAssets ( github , context , core , releaseId , assetsDir ) {
138112 const repo = { owner : context . repo . owner , repo : context . repo . repo } ;
139113 const existing = (
140114 await github . rest . repos . listReleaseAssets ( {
@@ -144,7 +118,11 @@ export async function uploadChartAssets(github, context, core, releaseId, chartD
144118 } )
145119 ) . data ;
146120
147- const files = fs . readdirSync ( chartDir ) . filter ( ( f ) => f . endsWith ( ".tgz" ) ) ;
121+ const files = fs . readdirSync ( assetsDir ) . filter ( ( f ) => {
122+ const stat = fs . statSync ( path . join ( assetsDir , f ) ) ;
123+ return stat . isFile ( ) ;
124+ } ) ;
125+
148126 for ( const file of files ) {
149127 const dup = existing . find ( ( a ) => a . name === file ) ;
150128 if ( dup ) {
@@ -154,18 +132,21 @@ export async function uploadChartAssets(github, context, core, releaseId, chartD
154132 asset_id : dup . id ,
155133 } ) ;
156134 }
157- const data = fs . readFileSync ( path . join ( chartDir , file ) ) ;
135+ const data = fs . readFileSync ( path . join ( assetsDir , file ) ) ;
158136 await github . rest . repos . uploadReleaseAsset ( {
159137 ...repo ,
160138 release_id : releaseId ,
161139 name : file ,
162140 data,
163141 headers : {
164- "content-type" : "application/gzip " ,
142+ "content-type" : "application/octet-stream " ,
165143 "content-length" : data . length ,
166144 } ,
167145 } ) ;
146+ core . info ( `Uploaded: ${ file } ` ) ;
168147 }
148+
149+ return files . length ;
169150}
170151
171152/**
@@ -179,38 +160,112 @@ export async function writeSummary(core, data) {
179160 finalTag,
180161 rcTag,
181162 finalVersion,
163+ componentName,
182164 imageRepo,
183165 chartRepo,
184166 imageDigest,
185167 isLatest,
186168 highestFinalVersion,
169+ uploadedCount,
187170 releaseUrl,
188171 } = data ;
189172
190- const imageTags = isLatest
191- ? `${ imageRepo } :${ finalVersion } , ${ imageRepo } :latest`
192- : `${ imageRepo } :${ finalVersion } ` ;
173+ const rows = [
174+ [
175+ { data : "Field" , header : true } ,
176+ { data : "Value" , header : true } ,
177+ ] ,
178+ [ "Component" , componentName ] ,
179+ [ "Final Tag" , finalTag ] ,
180+ [ "Promoted from RC" , rcTag ] ,
181+ [ "Highest Final Version" , highestFinalVersion || "(none)" ] ,
182+ [ "GitHub Latest" , isLatest ? "Yes" : "No (older version)" ] ,
183+ [ "Uploaded Assets" , String ( uploadedCount ) ] ,
184+ ] ;
185+
186+ // Add optional OCI/Helm fields when present
187+ if ( imageRepo ) {
188+ const imageTags = isLatest
189+ ? `${ imageRepo } :${ finalVersion } , ${ imageRepo } :latest`
190+ : `${ imageRepo } :${ finalVersion } ` ;
191+ rows . push ( [ "Image Tags" , imageTags ] ) ;
192+ }
193+ if ( imageDigest ) {
194+ rows . push ( [ "Image Digest" , imageDigest . substring ( 0 , 19 ) + "..." ] ) ;
195+ }
196+ if ( chartRepo ) {
197+ rows . push ( [ "Helm Chart" , `${ chartRepo } :${ finalVersion } ` ] ) ;
198+ }
193199
194200 await core . summary
195201 . addHeading ( "Final Release Published" )
196- . addTable ( [
197- [
198- { data : "Field" , header : true } ,
199- { data : "Value" , header : true } ,
200- ] ,
201- [ "Final Tag" , finalTag ] ,
202- [ "Promoted from RC" , rcTag ] ,
203- [ "Highest Final Version" , highestFinalVersion || "(none)" ] ,
204- [ "Image Tags" , imageTags ] ,
205- [ "Helm Chart" , `${ chartRepo } :${ finalVersion } ` ] ,
206- [
207- "Image Digest" ,
208- imageDigest ? imageDigest . substring ( 0 , 19 ) + "..." : "N/A" ,
209- ] ,
210- [ "GitHub Latest" , isLatest ? "Yes" : "No (older version)" ] ,
211- ] )
202+ . addTable ( rows )
212203 . addEOL ( )
213204 . addLink ( "View Release" , releaseUrl )
214205 . addEOL ( )
215206 . write ( ) ;
216207}
208+
209+ // --------------------------
210+ // GitHub Actions entrypoint
211+ // --------------------------
212+
213+ /**
214+ * Publish a final GitHub release by promoting an RC.
215+ *
216+ * Required env vars:
217+ * FINAL_TAG, FINAL_VERSION, RC_TAG, COMPONENT_NAME, ASSETS_DIR, NOTES_FILE,
218+ * SET_LATEST, HIGHEST_FINAL_VERSION
219+ *
220+ * Optional env vars (for summary):
221+ * IMAGE_REPO, IMAGE_DIGEST, CHART_REPO
222+ *
223+ * @param {import('@actions/github-script').AsyncFunctionArguments } args
224+ */
225+ export default async function publishFinalRelease ( { github, context, core } ) {
226+ const {
227+ FINAL_TAG : finalTag ,
228+ FINAL_VERSION : finalVersion ,
229+ RC_TAG : rcTag ,
230+ COMPONENT_NAME : componentName ,
231+ ASSETS_DIR : assetsDir ,
232+ NOTES_FILE : notesFile ,
233+ SET_LATEST : setLatest ,
234+ HIGHEST_FINAL_VERSION : highestFinalVersion ,
235+ // Optional — only used in summary
236+ IMAGE_REPO : imageRepo ,
237+ IMAGE_DIGEST : imageDigest ,
238+ CHART_REPO : chartRepo ,
239+ } = process . env ;
240+
241+ if ( ! finalTag || ! finalVersion || ! rcTag || ! componentName || ! assetsDir || ! notesFile ) {
242+ core . setFailed (
243+ "Missing required env vars: FINAL_TAG, FINAL_VERSION, RC_TAG, COMPONENT_NAME, ASSETS_DIR, NOTES_FILE" ,
244+ ) ;
245+ return ;
246+ }
247+
248+ const isLatest = setLatest === "true" ;
249+ const notes = prepareReleaseNotes ( notesFile , rcTag , finalTag ) ;
250+ const release = await getOrCreateRelease ( github , context , {
251+ finalTag,
252+ finalVersion,
253+ componentName,
254+ notes,
255+ isLatest,
256+ } ) ;
257+ const uploadedCount = await uploadAssets ( github , context , core , release . id , assetsDir ) ;
258+ await writeSummary ( core , {
259+ finalTag,
260+ rcTag,
261+ finalVersion,
262+ componentName,
263+ imageRepo,
264+ chartRepo,
265+ imageDigest,
266+ isLatest,
267+ highestFinalVersion,
268+ uploadedCount,
269+ releaseUrl : release . html_url ,
270+ } ) ;
271+ }
0 commit comments