88 * existing gutenberg directory is removed before extraction.
99 *
1010 * The artifact is identified by the "gutenberg.sha" value in the root
11- * package.json, which is used as the OCI image tag for the gutenberg-build
12- * package on GitHub Container Registry.
11+ * package.json, which is used as the OCI tag for the gutenberg-wp-develop-build
12+ * package on GitHub Container Registry. The value is normally a Git SHA, but
13+ * may also be a mutable tag (e.g. "trunk", "pr-12345") in a pull request that
14+ * wants to track the latest build of a stream. When the ref is a mutable tag,
15+ * the script resolves it to the immutable SHA tag for the actual blob fetch
16+ * and falls back to the mutable tag's manifest when the immutable tag is
17+ * unavailable.
1318 *
1419 * @package WordPress
1520 */
1621
1722const { spawn } = require ( 'child_process' ) ;
1823const fs = require ( 'fs' ) ;
19- const { Writable } = require ( 'stream' ) ;
24+ const { Readable } = require ( 'stream' ) ;
2025const { pipeline } = require ( 'stream/promises' ) ;
2126const zlib = require ( 'zlib' ) ;
22- const { gutenbergDir, readGutenbergConfig } = require ( './utils' ) ;
27+ const {
28+ gutenbergDir,
29+ readGutenbergConfig,
30+ fetchGhcrToken,
31+ fetchManifest,
32+ } = require ( './utils' ) ;
33+
34+ /**
35+ * Resolve the manifest to use for downloading.
36+ *
37+ * For immutable refs (SHA values), the ref is used directly.
38+ *
39+ * For mutable refs, the mutable tag's manifest is fetched first and the
40+ * `image.revision` annotation is read. The corresponding immutable SHA tag is
41+ * then preferred. If the immutable SHA tag is unavailable, fall back to the
42+ * manifest already fetched via the mutable tag.
43+ *
44+ * @param {{ ref: string, ghcrRepo: string, isMutable: boolean } } config
45+ * @param {string } token
46+ * @return {Promise<{ manifest: Record<string, any>, resolvedRef: string }> }
47+ */
48+ async function resolveDownloadManifest ( config , token ) {
49+ const { ref, ghcrRepo, isMutable } = config ;
50+
51+ const initialManifest = await fetchManifest ( ref , ghcrRepo , token ) ;
52+
53+ if ( ! isMutable ) {
54+ return { manifest : initialManifest , resolvedRef : ref } ;
55+ }
56+
57+ const revision =
58+ initialManifest ?. annotations ?. [ 'org.opencontainers.image.revision' ] ;
59+ if ( ! revision ) {
60+ console . log (
61+ `ℹ️ No image.revision annotation on "${ ref } "; using mutable tag for download.`
62+ ) ;
63+ return { manifest : initialManifest , resolvedRef : ref } ;
64+ }
65+
66+ try {
67+ const immutableManifest = await fetchManifest ( revision , ghcrRepo , token ) ;
68+ return { manifest : immutableManifest , resolvedRef : revision } ;
69+ } catch ( error ) {
70+ if ( /** @type {{ status?: number } } */ ( error ) . status === 404 ) {
71+ console . log (
72+ `ℹ️ Immutable SHA tag ${ revision } unavailable; falling back to mutable tag "${ ref } ".`
73+ ) ;
74+ return { manifest : initialManifest , resolvedRef : ref } ;
75+ }
76+ throw error ;
77+ }
78+ }
2379
2480/**
2581 * Main execution function.
@@ -31,61 +87,56 @@ async function main() {
3187 * Read Gutenberg configuration from package.json.
3288 *
3389 * Note: ghcr stands for GitHub Container Registry where wordpress-develop ready builds of the Gutenberg plugin
34- * are published on every repository push event .
90+ * are published by the Gutenberg build-plugin-zip workflow .
3591 */
36- let sha , ghcrRepo ;
92+ let config ;
3793 try {
38- ( { sha, ghcrRepo } = readGutenbergConfig ( ) ) ;
39- console . log ( ` SHA: ${ sha } ` ) ;
40- console . log ( ` GHCR repository: ${ ghcrRepo } ` ) ;
94+ config = readGutenbergConfig ( ) ;
95+ console . log (
96+ ` Ref: ${ config . ref } ${
97+ config . isMutable ? ' (mutable tag)' : ''
98+ } `
99+ ) ;
100+ console . log ( ` GHCR repository: ${ config . ghcrRepo } ` ) ;
41101 } catch ( error ) {
42- console . error ( '❌ Error reading package.json:' , error . message ) ;
102+ console . error ( '❌ Error reading package.json:' , /** @type { Error } */ ( error ) . message ) ;
43103 process . exit ( 1 ) ;
44104 }
45105
46106 // Step 1: Get an anonymous GHCR token for pulling.
47107 console . log ( '\n🔑 Fetching GHCR token...' ) ;
48108 let token ;
49109 try {
50- const response = await fetch ( `https://ghcr.io/token?scope=repository:${ ghcrRepo } :pull&service=ghcr.io` ) ;
51- if ( ! response . ok ) {
52- throw new Error ( `Failed to fetch token: ${ response . status } ${ response . statusText } ` ) ;
53- }
54- const data = await response . json ( ) ;
55- token = data . token ;
56- if ( ! token ) {
57- throw new Error ( 'No token in response' ) ;
58- }
110+ token = await fetchGhcrToken ( config . ghcrRepo ) ;
59111 console . log ( '✅ Token acquired' ) ;
60112 } catch ( error ) {
61- console . error ( '❌ Failed to fetch token:' , error . message ) ;
113+ console . error ( '❌ Failed to fetch token:' , /** @type { Error } */ ( error ) . message ) ;
62114 process . exit ( 1 ) ;
63115 }
64116
65- // Step 2: Get the manifest to find the blob digest .
66- console . log ( `\n📋 Fetching manifest for ${ sha } ...` ) ;
67- let digest ;
117+ // Step 2: Resolve the manifest to use for download .
118+ console . log ( `\n📋 Fetching manifest for ${ config . ref } ...` ) ;
119+ let manifest , resolvedRef ;
68120 try {
69- const response = await fetch ( `https://ghcr.io/v2/${ ghcrRepo } /manifests/${ sha } ` , {
70- headers : {
71- Authorization : `Bearer ${ token } ` ,
72- Accept : 'application/vnd.oci.image.manifest.v1+json' ,
73- } ,
74- } ) ;
75- if ( ! response . ok ) {
76- throw new Error ( `Failed to fetch manifest: ${ response . status } ${ response . statusText } ` ) ;
77- }
78- const manifest = await response . json ( ) ;
79- digest = manifest ?. layers ?. [ 0 ] ?. digest ;
80- if ( ! digest ) {
81- throw new Error ( 'No layer digest found in manifest' ) ;
121+ ( { manifest, resolvedRef } = await resolveDownloadManifest (
122+ config ,
123+ token
124+ ) ) ;
125+ if ( resolvedRef !== config . ref ) {
126+ console . log ( ` Resolved to immutable SHA tag: ${ resolvedRef } ` ) ;
82127 }
83- console . log ( `✅ Blob digest: ${ digest } ` ) ;
84128 } catch ( error ) {
85- console . error ( '❌ Failed to fetch manifest:' , error . message ) ;
129+ console . error ( '❌ Failed to fetch manifest:' , /** @type { Error } */ ( error ) . message ) ;
86130 process . exit ( 1 ) ;
87131 }
88132
133+ const digest = manifest ?. layers ?. [ 0 ] ?. digest ;
134+ if ( ! digest ) {
135+ console . error ( '❌ No layer digest found in manifest' ) ;
136+ process . exit ( 1 ) ;
137+ }
138+ console . log ( `✅ Blob digest: ${ digest } ` ) ;
139+
89140 // Remove existing gutenberg directory so the extraction is clean.
90141 if ( fs . existsSync ( gutenbergDir ) ) {
91142 console . log ( '\n🗑️ Removing existing gutenberg directory...' ) ;
@@ -100,14 +151,17 @@ async function main() {
100151 */
101152 console . log ( `\n📥 Downloading and extracting artifact...` ) ;
102153 try {
103- const response = await fetch ( `https://ghcr.io/v2/${ ghcrRepo } /blobs/${ digest } ` , {
154+ const response = await fetch ( `https://ghcr.io/v2/${ config . ghcrRepo } /blobs/${ digest } ` , {
104155 headers : {
105156 Authorization : `Bearer ${ token } ` ,
106157 } ,
107158 } ) ;
108159 if ( ! response . ok ) {
109160 throw new Error ( `Failed to download blob: ${ response . status } ${ response . statusText } ` ) ;
110161 }
162+ if ( ! response . body ) {
163+ throw new Error ( 'Blob response has no body' ) ;
164+ }
111165
112166 /*
113167 * Spawn tar to read from stdin and extract into gutenbergDir.
@@ -117,6 +171,7 @@ async function main() {
117171 stdio : [ 'pipe' , 'inherit' , 'inherit' ] ,
118172 } ) ;
119173
174+ /** @type {Promise<void> } */
120175 const tarDone = new Promise ( ( resolve , reject ) => {
121176 tar . on ( 'close' , ( code ) => {
122177 if ( code !== 0 ) {
@@ -134,16 +189,18 @@ async function main() {
134189 * consistent and means tar only sees plain tar data on stdin.
135190 */
136191 await pipeline (
137- response . body ,
192+ Readable . fromWeb (
193+ /** @type {import('stream/web').ReadableStream } */ ( response . body )
194+ ) ,
138195 zlib . createGunzip ( ) ,
139- Writable . toWeb ( tar . stdin ) ,
196+ tar . stdin ,
140197 ) ;
141198
142199 await tarDone ;
143200
144201 console . log ( '✅ Download and extraction complete' ) ;
145202 } catch ( error ) {
146- console . error ( '❌ Download/extraction failed:' , error . message ) ;
203+ console . error ( '❌ Download/extraction failed:' , /** @type { Error } */ ( error ) . message ) ;
147204 process . exit ( 1 ) ;
148205 }
149206
0 commit comments