1- name : ' Publish Composer Packages (TEST) '
1+ name : ' Publish Composer Packages + Deploy Pages '
22
3- # TEST harness for the Satis-replacement pipeline. Runs on
4- # ASUWebPlatforms/webspark-composer-test (in place of webspark-mirror) and
5- # publishes to ASUWebPlatforms/composer-packages-test (in place of
6- # composer-packages). Auth uses a service-user PAT (secrets.SERVICE_USER_PAT).
3+ # Single-repo Composer registry pipeline. Everything happens here in
4+ # ASUWebPlatforms/webspark-composer-test:
75#
8- # To promote to production: change TARGET_REPO + the repo gate back to the
9- # live names and switch the resolve-token action to the GitHub App path.
6+ # ensure-release -> create (once) the GitHub Release that holds this ref's
7+ # zip assets, on THIS repo.
8+ # publish (matrix)-> zip each package subdirectory and upload it as a Release
9+ # asset, then verify it downloads + checksums.
10+ # rebuild -> merge the new version records into the committed
11+ # packages.json accumulator, commit it back to main, and
12+ # deploy packages.json + index.html to GitHub Pages.
13+ #
14+ # This replaces the former two-repo design (publisher + separate registry repo
15+ # wired together by a cross-repo token and repository_dispatch). Because the
16+ # registry now lives in the same repo, the built-in GITHUB_TOKEN can do
17+ # everything -- no PAT, no GitHub App, no resolve-token action.
18+ #
19+ # Note on loops: pushes made with GITHUB_TOKEN do NOT trigger new workflow
20+ # runs, so the bot's packages.json commit cannot re-trigger this workflow. The
21+ # "[skip ci]" marker on that commit is kept as belt-and-suspenders.
1022
1123on :
1224 push :
1830 - ' [0-9]+.[0-9]+.[0-9]+'
1931 workflow_dispatch :
2032
21- # Don't let two publishes for the same ref race each other.
33+ # Serialize the whole pipeline so concurrent pushes never race on the release,
34+ # the packages.json commit, or the Pages deploy. Queue them (do NOT cancel) --
35+ # cancelling mid-push/mid-deploy can drop an update or wedge the Pages backend.
2236concurrency :
23- group : composer-packages-publish-${{ github.ref }}
37+ group : composer-packages-publish
2438 cancel-in-progress : false
2539
2640env :
27- # Single place to flip between test and production targets.
28- PUBLISHER_REPO : ASUWebPlatforms/webspark-composer-test
29- TARGET_REPO : ASUWebPlatforms/composer-packages-test
41+ # Single place to flip between test and production. For production this
42+ # becomes the live repo (e.g. ASUWebPlatforms/webspark-mirror) and its Pages
43+ # URL; nothing else in the workflow hard-codes the repo.
44+ REPO : ASUWebPlatforms/webspark-composer-test
45+ BASE_URL : https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download
46+ PAGES_URL : https://asuwebplatforms.github.io/webspark-composer-test
3047
3148jobs :
3249 # ---------------------------------------------------------------------------
@@ -35,17 +52,14 @@ jobs:
3552 ensure-release :
3653 if : github.repository == 'ASUWebPlatforms/webspark-composer-test'
3754 runs-on : ubuntu-latest
55+ permissions :
56+ contents : write # create the release on this repo
3857 outputs :
3958 tag : ${{ steps.compute.outputs.tag }}
4059 version : ${{ steps.compute.outputs.version }}
4160 steps :
4261 - uses : actions/checkout@v4
4362
44- - uses : ./.github/actions/resolve-token
45- id : token
46- with :
47- service-user-pat : ${{ secrets.SERVICE_USER_PAT }}
48-
4963 - id : compute
5064 shell : bash
5165 run : |
@@ -60,14 +74,14 @@ jobs:
6074 echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
6175 echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
6276
63- - name : Ensure release exists on target repo
77+ - name : Ensure release exists
6478 env :
65- GH_TOKEN : ${{ steps.token.outputs.token }}
79+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
6680 TAG : ${{ steps.compute.outputs.tag }}
6781 run : |
68- if ! gh release view "$TAG" --repo "$TARGET_REPO " >/dev/null 2>&1; then
82+ if ! gh release view "$TAG" --repo "$REPO " >/dev/null 2>&1; then
6983 gh release create "$TAG" \
70- --repo "$TARGET_REPO " \
84+ --repo "$REPO " \
7185 --title "$TAG" \
7286 --notes "Composer package artifacts for $TAG" \
7387 ${{ steps.compute.outputs.version == 'dev-main' && '--prerelease' || '' }}
8195 publish :
8296 needs : ensure-release
8397 runs-on : ubuntu-latest
98+ permissions :
99+ contents : write # upload assets to the release
84100 strategy :
85101 fail-fast : false
86102 matrix :
@@ -94,11 +110,6 @@ jobs:
94110 steps :
95111 - uses : actions/checkout@v4
96112
97- - uses : ./.github/actions/resolve-token
98- id : token
99- with :
100- service-user-pat : ${{ secrets.SERVICE_USER_PAT }}
101-
102113 - name : Package subdirectory
103114 env :
104115 TAG : ${{ needs.ensure-release.outputs.tag }}
@@ -108,7 +119,7 @@ jobs:
108119
109120 - name : Upload asset (retry up to 2x, then fail)
110121 env :
111- GH_TOKEN : ${{ steps.token.outputs.token }}
122+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
112123 TAG : ${{ needs.ensure-release.outputs.tag }}
113124 run : |
114125 set -euo pipefail
@@ -117,7 +128,7 @@ jobs:
117128 max=3 # 1 initial + 2 retries
118129 until [ "$attempt" -gt "$max" ]; do
119130 echo "Upload attempt $attempt/$max for $ZIP"
120- if gh release upload "$TAG" "$ZIP" --repo "$TARGET_REPO " --clobber; then
131+ if gh release upload "$TAG" "$ZIP" --repo "$REPO " --clobber; then
121132 echo "Upload succeeded."
122133 break
123134 fi
@@ -135,7 +146,7 @@ jobs:
135146 run : |
136147 set -euo pipefail
137148 META="$(ls dist/*.json)"
138- URL="https://github.com/${TARGET_REPO }/releases/download/${TAG}/$(jq -r '.filename' "$META")"
149+ URL="https://github.com/${REPO }/releases/download/${TAG}/$(jq -r '.filename' "$META")"
139150 EXPECTED="$(jq -r '.shasum' "$META")"
140151 # -L follows the 302 to the signed objects URL.
141152 curl -fsSL "$URL" -o downloaded.zip
@@ -146,26 +157,49 @@ jobs:
146157 fi
147158 echo "Verified $URL"
148159
149- - name : Stash metadata record for dispatch
160+ - name : Stash metadata record for merge
150161 uses : actions/upload-artifact@v4
151162 with :
152163 name : meta-${{ strategy.job-index }}
153164 path : dist/*.json
154165 retention-days : 1
155166
156167 # ---------------------------------------------------------------------------
157- # 3) After ALL uploads succeed, fire ONE repository_dispatch with metadata .
168+ # 3) Merge records into packages.json, commit, and deploy to Pages .
158169 # ---------------------------------------------------------------------------
159- dispatch- rebuild :
170+ rebuild :
160171 needs : publish
161172 runs-on : ubuntu-latest
173+ # Fail cleanly if a Pages deploy ever wedges, instead of hanging until a
174+ # human cancels it. A healthy run finishes well under a minute.
175+ timeout-minutes : 15
176+ permissions :
177+ contents : write # commit the updated packages.json back to this repo
178+ pages : write # deploy to Pages
179+ id-token : write # required by actions/deploy-pages (OIDC)
162180 steps :
163181 - uses : actions/checkout@v4
164182
165- - uses : ./.github/actions/resolve-token
166- id : token
167- with :
168- service-user-pat : ${{ secrets.SERVICE_USER_PAT }}
183+ - name : Guard - packages.json is valid and well-shaped
184+ run : |
185+ set -euo pipefail
186+ # Fail fast (and clearly) if the committed accumulator is missing,
187+ # not valid JSON, or not the expected { "packages": { ... } } shape.
188+ if [ ! -f packages.json ]; then
189+ echo "::error file=packages.json::packages.json is missing from the repo root." >&2
190+ exit 1
191+ fi
192+ if ! jq empty packages.json 2>/dev/null; then
193+ echo "::error file=packages.json::packages.json is not valid JSON." >&2
194+ head -n 20 packages.json >&2 || true
195+ exit 1
196+ fi
197+ if [ "$(jq -r 'type' packages.json)" != "object" ] \
198+ || [ "$(jq -r '.packages | type' packages.json)" != "object" ]; then
199+ echo "::error file=packages.json::packages.json must be an object with a top-level \"packages\" object." >&2
200+ exit 1
201+ fi
202+ echo "packages.json OK ($(jq '.packages | length' packages.json) package(s))."
169203
170204 - name : Collect all metadata records
171205 uses : actions/download-artifact@v4
@@ -174,27 +208,92 @@ jobs:
174208 path : meta
175209 merge-multiple : true
176210
177- - name : Build dispatch payload
178- id : payload
211+ - name : Build records.json
179212 run : |
180213 set -euo pipefail
181- # Combine every per-package record into a single JSON array.
182- jq -s '.' meta/*.json > records.json
183- echo "Records to dispatch:"
214+ # Combine every per-package record into a single JSON array. If there
215+ # are somehow no records (shouldn't happen after a successful publish
216+ # matrix), fall back to an empty array so we just redeploy as-is.
217+ if ls meta/*.json >/dev/null 2>&1; then
218+ jq -s '.' meta/*.json > records.json
219+ else
220+ echo "No metadata records found; redeploying existing packages.json."
221+ echo '[]' > records.json
222+ fi
184223 jq . records.json
185- # Build the FULL request body with a properly nested client_payload.
186- # gh api -f/-F send flat form fields and will NOT construct nested
187- # objects (client_payload[records] would not become real nested JSON),
188- # so we assemble the body explicitly and send it via --input.
189- # client_payload has a documented ~64KB cap; 6 small records fit.
190- jq -n --argjson records "$(cat records.json)" \
191- '{event_type: "packages-publish", client_payload: {records: $records}}' \
192- > dispatch-body.json
193- jq . dispatch-body.json
194-
195- - name : Dispatch rebuild on target repo
224+
225+ - name : Merge + commit + push (race-safe)
196226 env :
197- GH_TOKEN : ${{ steps.token.outputs.token }}
227+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
228+ run : |
229+ set -euo pipefail
230+ chmod +x scripts/merge-packages-json.sh
231+ git config user.name "ws2-release-bot"
232+ git config user.email "ws2-release-bot@asu.edu"
233+
234+ attempt=1
235+ max=5
236+ until [ "$attempt" -gt "$max" ]; do
237+ # Always re-apply the merge onto the freshest main. The merge is
238+ # additive/idempotent, so re-applying after a remote update is
239+ # always correct.
240+ git fetch origin main
241+ git reset --hard origin/main
242+
243+ ./scripts/merge-packages-json.sh packages.json records.json "$BASE_URL"
244+
245+ if git diff --quiet -- packages.json; then
246+ echo "packages.json already up to date on origin/main; nothing to push."
247+ break
248+ fi
249+
250+ git add packages.json
251+ git commit -m "Update packages.json [skip ci]"
252+
253+ if git push origin HEAD:main; then
254+ echo "Pushed on attempt $attempt."
255+ break
256+ fi
257+
258+ if [ "$attempt" -eq "$max" ]; then
259+ echo "::error::failed to push packages.json after $max attempts (persistent contention)" >&2
260+ exit 1
261+ fi
262+ echo "Push rejected (remote moved); retrying ($attempt/$max)..."
263+ attempt=$((attempt+1))
264+ sleep $((attempt * 3))
265+ done
266+
267+ - name : Prepare Pages artifact
198268 run : |
199269 set -euo pipefail
200- gh api --method POST "repos/${TARGET_REPO}/dispatches" --input dispatch-body.json
270+ mkdir -p _site
271+ cp packages.json _site/packages.json
272+ # Optional human index, if present in the repo root.
273+ [ -f index.html ] && cp index.html _site/index.html || true
274+
275+ - uses : actions/configure-pages@v5
276+ - uses : actions/upload-pages-artifact@v3
277+ with :
278+ path : _site
279+ - id : deploy
280+ uses : actions/deploy-pages@v4
281+
282+ - name : Verify deployed packages.json
283+ run : |
284+ set -euo pipefail
285+ # Give Pages a moment to propagate the new deploy.
286+ sleep 10
287+ curl -fsSL "${PAGES_URL}/packages.json" -o served.json
288+ jq empty served.json
289+ # If this run carried records, confirm each name->version is present.
290+ if [ "$(jq 'length' records.json)" -gt 0 ]; then
291+ while read -r name version; do
292+ if [ "$(jq --arg n "$name" --arg v "$version" \
293+ '.packages[$n][$v] != null' served.json)" != "true" ]; then
294+ echo "::error::served packages.json missing ${name}@${version}" >&2
295+ exit 1
296+ fi
297+ done < <(jq -r '.[] | "\(.name) \(.version)"' records.json)
298+ fi
299+ echo "Verified ${PAGES_URL}/packages.json"
0 commit comments