diff --git a/.github/actions/resolve-token/action.yml b/.github/actions/resolve-token/action.yml
deleted file mode 100644
index 377b48da..00000000
--- a/.github/actions/resolve-token/action.yml
+++ /dev/null
@@ -1,84 +0,0 @@
-# ---------------------------------------------------------------------------
-# Composite action: resolve the cross-repo token in ONE place.
-#
-# This is the single switch point between auth models.
-#
-# OPTION A — GitHub App (production / live org)
-# OPTION B — service user PAT (CURRENTLY ACTIVE: test repos + future org)
-#
-# The test setup (ASUWebPlatforms/webspark-composer-test) uses a PAT because a
-# fresh test repo has no GitHub App installed. This also exercises the exact
-# auth model the future org prefers.
-#
-# To switch back to the App: comment OPTION B, uncomment OPTION A, and point
-# the `token` output value at steps.app-token.outputs.token.
-# ---------------------------------------------------------------------------
-name: 'Resolve cross-repo token'
-description: >-
- Mints the token used for cross-repo release uploads, repository_dispatch,
- and committing packages.json. Switch between GitHub App and service-user PAT
- here, in one place.
-
-inputs:
- # --- Inputs for OPTION A (GitHub App) ---
- app-id:
- description: 'GitHub App ID (vars.MIRROR_APP_ID). Used by OPTION A only.'
- required: false
- app-private-key:
- description: 'GitHub App private key (secrets.WEBSPARK_MIRROR_SECRET). OPTION A only.'
- required: false
- owner:
- description: 'Org/owner the token should be scoped to. OPTION A only.'
- required: false
- repositories:
- description: 'Comma-separated repo list the App token may access. OPTION A only.'
- required: false
-
- # --- Inputs for OPTION B (service user PAT) ---
- service-user-pat:
- description: 'Service user fine-grained PAT (secrets.SERVICE_USER_PAT). OPTION B only.'
- required: false
-
-outputs:
- token:
- description: 'The resolved token to use for gh / API / git operations.'
- # ACTIVE: OPTION B (service user PAT).
- value: ${{ steps.pat.outputs.token }}
- # OPTION A value (use this line instead when on the GitHub App):
- # value: ${{ steps.app-token.outputs.token }}
-
-runs:
- using: 'composite'
- steps:
- # ===== OPTION A: GitHub App (PRODUCTION) ==============================
- # Uncomment this step, comment OPTION B below, and switch the `token`
- # output value above to steps.app-token.outputs.token.
- #
- # - id: app-token
- # uses: actions/create-github-app-token@v1
- # with:
- # app-id: ${{ inputs.app-id }}
- # private-key: ${{ inputs.app-private-key }}
- # owner: ${{ inputs.owner }}
- # repositories: ${{ inputs.repositories }}
-
- # ===== OPTION B: service user PAT (CURRENTLY ACTIVE) =================
- # Prerequisites for OPTION B:
- # - A machine/service user with WRITE on the publisher repo
- # (webspark-composer-test) AND the target repo
- # (composer-packages-test) -- directly or via a narrow team.
- # - A fine-grained PAT scoped to ONLY those two repos with
- # Contents: read/write (release assets + committing packages.json)
- # plus the permission needed to send repository_dispatch.
- # - PAT must be SSO-authorized if the org enforces SAML/SSO.
- # - Store it as the secret SERVICE_USER_PAT on the publisher repo.
- - id: pat
- shell: bash
- env:
- SERVICE_USER_PAT: ${{ inputs.service-user-pat }}
- run: |
- if [ -z "${SERVICE_USER_PAT}" ]; then
- echo "::error::SERVICE_USER_PAT is empty (set the secret on the repo)" >&2
- exit 1
- fi
- echo "token=${SERVICE_USER_PAT}" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/composer-packages-publish.yml b/.github/workflows/composer-packages-publish.yml
index 2736eae7..9158415e 100644
--- a/.github/workflows/composer-packages-publish.yml
+++ b/.github/workflows/composer-packages-publish.yml
@@ -1,12 +1,24 @@
-name: 'Publish Composer Packages (TEST)'
+name: 'Publish Composer Packages + Deploy Pages'
-# TEST harness for the Satis-replacement pipeline. Runs on
-# ASUWebPlatforms/webspark-composer-test (in place of webspark-mirror) and
-# publishes to ASUWebPlatforms/composer-packages-test (in place of
-# composer-packages). Auth uses a service-user PAT (secrets.SERVICE_USER_PAT).
+# Single-repo Composer registry pipeline. Everything happens here in
+# ASUWebPlatforms/webspark-composer-test:
#
-# To promote to production: change TARGET_REPO + the repo gate back to the
-# live names and switch the resolve-token action to the GitHub App path.
+# ensure-release -> create (once) the GitHub Release that holds this ref's
+# zip assets, on THIS repo.
+# publish (matrix)-> zip each package subdirectory and upload it as a Release
+# asset, then verify it downloads + checksums.
+# rebuild -> merge the new version records into the committed
+# packages.json accumulator, commit it back to main, and
+# deploy packages.json + index.html to GitHub Pages.
+#
+# This replaces the former two-repo design (publisher + separate registry repo
+# wired together by a cross-repo token and repository_dispatch). Because the
+# registry now lives in the same repo, the built-in GITHUB_TOKEN can do
+# everything -- no PAT, no GitHub App, no resolve-token action.
+#
+# Note on loops: pushes made with GITHUB_TOKEN do NOT trigger new workflow
+# runs, so the bot's packages.json commit cannot re-trigger this workflow. The
+# "[skip ci]" marker on that commit is kept as belt-and-suspenders.
on:
push:
@@ -18,15 +30,20 @@ on:
- '[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
-# Don't let two publishes for the same ref race each other.
+# Serialize the whole pipeline so concurrent pushes never race on the release,
+# the packages.json commit, or the Pages deploy. Queue them (do NOT cancel) --
+# cancelling mid-push/mid-deploy can drop an update or wedge the Pages backend.
concurrency:
- group: composer-packages-publish-${{ github.ref }}
+ group: composer-packages-publish
cancel-in-progress: false
env:
- # Single place to flip between test and production targets.
- PUBLISHER_REPO: ASUWebPlatforms/webspark-composer-test
- TARGET_REPO: ASUWebPlatforms/composer-packages-test
+ # Single place to flip between test and production. For production this
+ # becomes the live repo (e.g. ASUWebPlatforms/webspark-mirror) and its Pages
+ # URL; nothing else in the workflow hard-codes the repo.
+ REPO: ASUWebPlatforms/webspark-composer-test
+ BASE_URL: https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download
+ PAGES_URL: https://asuwebplatforms.github.io/webspark-composer-test
jobs:
# ---------------------------------------------------------------------------
@@ -35,17 +52,14 @@ jobs:
ensure-release:
if: github.repository == 'ASUWebPlatforms/webspark-composer-test'
runs-on: ubuntu-latest
+ permissions:
+ contents: write # create the release on this repo
outputs:
tag: ${{ steps.compute.outputs.tag }}
version: ${{ steps.compute.outputs.version }}
steps:
- uses: actions/checkout@v4
- - uses: ./.github/actions/resolve-token
- id: token
- with:
- service-user-pat: ${{ secrets.SERVICE_USER_PAT }}
-
- id: compute
shell: bash
run: |
@@ -60,14 +74,14 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- - name: Ensure release exists on target repo
+ - name: Ensure release exists
env:
- GH_TOKEN: ${{ steps.token.outputs.token }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.compute.outputs.tag }}
run: |
- if ! gh release view "$TAG" --repo "$TARGET_REPO" >/dev/null 2>&1; then
+ if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
gh release create "$TAG" \
- --repo "$TARGET_REPO" \
+ --repo "$REPO" \
--title "$TAG" \
--notes "Composer package artifacts for $TAG" \
${{ steps.compute.outputs.version == 'dev-main' && '--prerelease' || '' }}
@@ -81,6 +95,8 @@ jobs:
publish:
needs: ensure-release
runs-on: ubuntu-latest
+ permissions:
+ contents: write # upload assets to the release
strategy:
fail-fast: false
matrix:
@@ -94,11 +110,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: ./.github/actions/resolve-token
- id: token
- with:
- service-user-pat: ${{ secrets.SERVICE_USER_PAT }}
-
- name: Package subdirectory
env:
TAG: ${{ needs.ensure-release.outputs.tag }}
@@ -108,7 +119,7 @@ jobs:
- name: Upload asset (retry up to 2x, then fail)
env:
- GH_TOKEN: ${{ steps.token.outputs.token }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.ensure-release.outputs.tag }}
run: |
set -euo pipefail
@@ -117,7 +128,7 @@ jobs:
max=3 # 1 initial + 2 retries
until [ "$attempt" -gt "$max" ]; do
echo "Upload attempt $attempt/$max for $ZIP"
- if gh release upload "$TAG" "$ZIP" --repo "$TARGET_REPO" --clobber; then
+ if gh release upload "$TAG" "$ZIP" --repo "$REPO" --clobber; then
echo "Upload succeeded."
break
fi
@@ -135,7 +146,7 @@ jobs:
run: |
set -euo pipefail
META="$(ls dist/*.json)"
- URL="https://github.com/${TARGET_REPO}/releases/download/${TAG}/$(jq -r '.filename' "$META")"
+ URL="https://github.com/${REPO}/releases/download/${TAG}/$(jq -r '.filename' "$META")"
EXPECTED="$(jq -r '.shasum' "$META")"
# -L follows the 302 to the signed objects URL.
curl -fsSL "$URL" -o downloaded.zip
@@ -146,7 +157,7 @@ jobs:
fi
echo "Verified $URL"
- - name: Stash metadata record for dispatch
+ - name: Stash metadata record for merge
uses: actions/upload-artifact@v4
with:
name: meta-${{ strategy.job-index }}
@@ -154,18 +165,41 @@ jobs:
retention-days: 1
# ---------------------------------------------------------------------------
- # 3) After ALL uploads succeed, fire ONE repository_dispatch with metadata.
+ # 3) Merge records into packages.json, commit, and deploy to Pages.
# ---------------------------------------------------------------------------
- dispatch-rebuild:
+ rebuild:
needs: publish
runs-on: ubuntu-latest
+ # Fail cleanly if a Pages deploy ever wedges, instead of hanging until a
+ # human cancels it. A healthy run finishes well under a minute.
+ timeout-minutes: 15
+ permissions:
+ contents: write # commit the updated packages.json back to this repo
+ pages: write # deploy to Pages
+ id-token: write # required by actions/deploy-pages (OIDC)
steps:
- uses: actions/checkout@v4
- - uses: ./.github/actions/resolve-token
- id: token
- with:
- service-user-pat: ${{ secrets.SERVICE_USER_PAT }}
+ - name: Guard - packages.json is valid and well-shaped
+ run: |
+ set -euo pipefail
+ # Fail fast (and clearly) if the committed accumulator is missing,
+ # not valid JSON, or not the expected { "packages": { ... } } shape.
+ if [ ! -f packages.json ]; then
+ echo "::error file=packages.json::packages.json is missing from the repo root." >&2
+ exit 1
+ fi
+ if ! jq empty packages.json 2>/dev/null; then
+ echo "::error file=packages.json::packages.json is not valid JSON." >&2
+ head -n 20 packages.json >&2 || true
+ exit 1
+ fi
+ if [ "$(jq -r 'type' packages.json)" != "object" ] \
+ || [ "$(jq -r '.packages | type' packages.json)" != "object" ]; then
+ echo "::error file=packages.json::packages.json must be an object with a top-level \"packages\" object." >&2
+ exit 1
+ fi
+ echo "packages.json OK ($(jq '.packages | length' packages.json) package(s))."
- name: Collect all metadata records
uses: actions/download-artifact@v4
@@ -174,27 +208,92 @@ jobs:
path: meta
merge-multiple: true
- - name: Build dispatch payload
- id: payload
+ - name: Build records.json
run: |
set -euo pipefail
- # Combine every per-package record into a single JSON array.
- jq -s '.' meta/*.json > records.json
- echo "Records to dispatch:"
+ # Combine every per-package record into a single JSON array. If there
+ # are somehow no records (shouldn't happen after a successful publish
+ # matrix), fall back to an empty array so we just redeploy as-is.
+ if ls meta/*.json >/dev/null 2>&1; then
+ jq -s '.' meta/*.json > records.json
+ else
+ echo "No metadata records found; redeploying existing packages.json."
+ echo '[]' > records.json
+ fi
jq . records.json
- # Build the FULL request body with a properly nested client_payload.
- # gh api -f/-F send flat form fields and will NOT construct nested
- # objects (client_payload[records] would not become real nested JSON),
- # so we assemble the body explicitly and send it via --input.
- # client_payload has a documented ~64KB cap; 6 small records fit.
- jq -n --argjson records "$(cat records.json)" \
- '{event_type: "packages-publish", client_payload: {records: $records}}' \
- > dispatch-body.json
- jq . dispatch-body.json
-
- - name: Dispatch rebuild on target repo
+
+ - name: Merge + commit + push (race-safe)
env:
- GH_TOKEN: ${{ steps.token.outputs.token }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ chmod +x scripts/merge-packages-json.sh
+ git config user.name "ws2-release-bot"
+ git config user.email "ws2-release-bot@asu.edu"
+
+ attempt=1
+ max=5
+ until [ "$attempt" -gt "$max" ]; do
+ # Always re-apply the merge onto the freshest main. The merge is
+ # additive/idempotent, so re-applying after a remote update is
+ # always correct.
+ git fetch origin main
+ git reset --hard origin/main
+
+ ./scripts/merge-packages-json.sh packages.json records.json "$BASE_URL"
+
+ if git diff --quiet -- packages.json; then
+ echo "packages.json already up to date on origin/main; nothing to push."
+ break
+ fi
+
+ git add packages.json
+ git commit -m "Update packages.json [skip ci]"
+
+ if git push origin HEAD:main; then
+ echo "Pushed on attempt $attempt."
+ break
+ fi
+
+ if [ "$attempt" -eq "$max" ]; then
+ echo "::error::failed to push packages.json after $max attempts (persistent contention)" >&2
+ exit 1
+ fi
+ echo "Push rejected (remote moved); retrying ($attempt/$max)..."
+ attempt=$((attempt+1))
+ sleep $((attempt * 3))
+ done
+
+ - name: Prepare Pages artifact
run: |
set -euo pipefail
- gh api --method POST "repos/${TARGET_REPO}/dispatches" --input dispatch-body.json
+ mkdir -p _site
+ cp packages.json _site/packages.json
+ # Optional human index, if present in the repo root.
+ [ -f index.html ] && cp index.html _site/index.html || true
+
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+ - id: deploy
+ uses: actions/deploy-pages@v4
+
+ - name: Verify deployed packages.json
+ run: |
+ set -euo pipefail
+ # Give Pages a moment to propagate the new deploy.
+ sleep 10
+ curl -fsSL "${PAGES_URL}/packages.json" -o served.json
+ jq empty served.json
+ # If this run carried records, confirm each name->version is present.
+ if [ "$(jq 'length' records.json)" -gt 0 ]; then
+ while read -r name version; do
+ if [ "$(jq --arg n "$name" --arg v "$version" \
+ '.packages[$n][$v] != null' served.json)" != "true" ]; then
+ echo "::error::served packages.json missing ${name}@${version}" >&2
+ exit 1
+ fi
+ done < <(jq -r '.[] | "\(.name) \(.version)"' records.json)
+ fi
+ echo "Verified ${PAGES_URL}/packages.json"
diff --git a/.github/workflows/conventional-commit-pr.yml b/.github/workflows/conventional-commit-pr.yml
deleted file mode 100644
index 62ea72eb..00000000
--- a/.github/workflows/conventional-commit-pr.yml
+++ /dev/null
@@ -1,229 +0,0 @@
-name: 'Conventional Commit PR'
-
-on:
- pull_request:
- types:
- - opened
- - edited
- - synchronize
- - reopened
- # Run for pull requests targeting the main branch.
- branches:
- - main
- workflow_dispatch:
-
-jobs:
- conventional-commit-pr:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- contents: read
- steps:
- - uses: actions/checkout@v3
- - name: Conventional Commit In Pull Requests
- uses: amannn/action-semantic-pull-request@v6
- id: pr-conventional-commits
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- # Configure which types are allowed (newline-delimited).
- # These are regex patterns auto-wrapped in `^ $`.
- types: |
- a11y
- fix
- feat
- chore
- # Configure which scopes are allowed (newline-delimited).
- # These are regex patterns auto-wrapped in `^ $`.
- scopes: |
- webspark
- partner
- stack
- # Configure that a scope must always be provided.
- requireScope: true
-
- - name: Inject Jira Ticket into PR Title
- id: jira-validation
- if: always() && (steps.pr-conventional-commits.outputs.scope == 'webspark' || steps.pr-conventional-commits.outputs.scope == 'stack')
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PR_TITLE: ${{ github.event.pull_request.title }}
- PR_BODY: ${{ github.event.pull_request.body }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- run: |
- # 1. Extract the ticket
- TICKET="$(printf '%s' "$PR_BODY" | grep -oP 'https://asudev\.jira\.com/browse/\K[A-Z0-9]+-[0-9]+' || true)"
- echo "TICKET extracted: '$TICKET'"
-
- if [[ -z "$TICKET" ]]; then
- echo "error_message=No Jira ticket found in the pull request body with the format: https://asudev.jira.com/browse/PROJECT-123" >> $GITHUB_OUTPUT
- exit 1
- fi
-
- # 2. Check if ticket is already in title
- if [[ "$PR_TITLE" == *"$TICKET"* ]]; then
- echo "Ticket $TICKET is already in the title. Exiting."
- exit 0
- fi
-
- # 3. Build the new title by inserting ticket after the scope
- echo "Testing sed pattern..."
- NEW_TITLE="$(printf '%s' "$PR_TITLE" | sed -E "s/^([a-z0-9]+)\(([a-z]+)\): (.*)$/\1(\2): [$TICKET] \3/")"
- echo "Sed pattern result: '$NEW_TITLE'"
-
- # Debug: Test if the pattern matches
- if printf '%s' "$PR_TITLE" | grep -qE "^[a-z0-9]+\([a-z]+\): "; then
- echo "✓ Pattern matches!"
- else
- echo "✗ Pattern does NOT match"
- echo " Expected format: type(scope): description"
- echo " Got: $PR_TITLE"
- fi
-
- # 4. Only update if the title actually changed
- if [[ "$NEW_TITLE" != "$PR_TITLE" ]]; then
- echo "Title changed, updating PR..."
- gh pr edit "$PR_NUMBER" --title "$NEW_TITLE"
- echo "PR title updated successfully"
- else
- echo "Sed pattern did not match the title format. No changes made."
- fi
-
- - name: Comment PR title linting errors
- uses: marocchino/sticky-pull-request-comment@v2
- if: always() && (steps.pr-conventional-commits.outputs.error_message != null || steps.jira-validation.outputs.error_message != null)
- with:
- header: pr-title-lint-error
- message: |
- ⚠️ Warning ⚠️
- We require pull request titles to follow a customized subset of the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/#summary).
-
- Example:
- ```
- feat(webspark): [JIRA-1234] add button component
- ^ ^ ^
- | | |__ Subject with Jira ticket (required if the scope is "webspark" or "stack")
- | |_______ Scope
- |____________ Type
- ```
- We use 4 `types`:
- - a11y: a commit of the type a11y includes fixes that improve accessibility.
- - fix: a commit of the type fix patches a bug in your codebase.
- - feat: a commit of the type feat introduces a new feature to the codebase.
- - chore: a commit of the type chore is used for anything else, such as updating documentation, refactoring code, or performing other maintenance tasks.
-
- We also require a `scope`, which relates to where the change is being made.
- - webspark: changes made on the Webspark profile and associated modules/themes/etc.
- - partner: changes made by Trusted Partners on their custom code.
- - stack: changes made by the ET Web Team to the shared stack codebase.
-
- Please adjust your PR title as recommended below:
- ```
- ${{ steps.pr-conventional-commits.outputs.error_message }}${{ steps.jira-validation.outputs.error_message }}
- ```
-
- - name: Delete resolved PR title lint error comment
- if: always() && steps.pr-conventional-commits.outputs.error_message == null && steps.jira-validation.outputs.error_message == null
- uses: marocchino/sticky-pull-request-comment@v2
- with:
- header: pr-title-lint-error
- delete: true
-
- - name: Sync Conventional Labels
- if: github.event_action != 'closed' && steps.pr-conventional-commits.outputs.type != ''
- uses: actions/github-script@v6
- with:
- script: |
- const type = '${{ steps.pr-conventional-commits.outputs.type }}';
- const prTitle = '${{ github.event.pull_request.title }}';
- const scope = '${{ steps.pr-conventional-commits.outputs.scope }}';
-
- // Detect breaking change by looking for ! before the colon
- // Pattern: type(scope)!: description
- const isBreaking = /^[a-z]+\([a-z]+\)!:/.test(prTitle);
-
- console.log('Type:', type);
- console.log('PR Title:', prTitle);
- console.log('Is Breaking:', isBreaking);
-
- const typeMapping = {
- 'feat': { name: 'feature', color: '0E8A16' }, // Green
- 'fix': { name: 'bug fix', color: 'd876e3' }, // Pink
- 'chore': { name: 'maintenance', color: 'FBCA04' }, // Yellow
- 'a11y': { name: 'a11y', color: '0075CA' } // Blue
- };
- const scopeMapping = {
- 'webspark': { name: 'webspark', color: '8C1D40' }, // Maroon
- 'partner': { name: 'trusted-partner', color: 'FF7F32' }, // Orange
- 'stack': { name: 'stack', color: '008672' } // Aquamarine
- };
- const breakingLabelName = 'breaking-change';
-
- // 1. Get all existing labels in the repo
- const repoLabels = await github.rest.issues.listLabelsForRepo({
- owner: context.repo.owner,
- repo: context.repo.repo
- });
- const existingLabelNames = repoLabels.data.map(l => l.name);
-
- // 2. Helper to create label if it doesn't exist
- async function ensureLabelExists(name, color) {
- if (!existingLabelNames.includes(name)) {
- await github.rest.issues.createLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: name,
- color: color
- });
- }
- }
-
- // 3. Ensure our target labels exist
- if (typeMapping[type]) await ensureLabelExists(typeMapping[type].name, typeMapping[type].color);
- if (scopeMapping[scope]) await ensureLabelExists(scopeMapping[scope].name, scopeMapping[scope].color);
- await ensureLabelExists(breakingLabelName, 'D73A4A');
-
- // 4. Update Labels on PR
- const currentLabels = await github.rest.issues.listLabelsOnIssue({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number
- });
- const currentLabelNames = currentLabels.data.map(l => l.name);
-
- let labelsToSet = [...currentLabelNames];
-
- // Add new type label, remove old type labels if they changed
- Object.values(typeMapping).forEach(config => {
- if (config.name === typeMapping[type]?.name) {
- if (!labelsToSet.includes(config.name)) labelsToSet.push(config.name);
- } else {
- labelsToSet = labelsToSet.filter(l => l !== config.name);
- }
- });
-
- // Add scope label, remove old scope labels if they changed
- Object.values(scopeMapping).forEach(config => {
- if (config.name === scopeMapping[scope]?.name) {
- if (!labelsToSet.includes(config.name)) labelsToSet.push(config.name);
- } else {
- labelsToSet = labelsToSet.filter(l => l !== config.name);
- }
- });
-
- // Handle breaking-change toggle
- if (isBreaking) {
- if (!labelsToSet.includes(breakingLabelName)) labelsToSet.push(breakingLabelName);
- } else {
- labelsToSet = labelsToSet.filter(l => l !== breakingLabelName);
- }
-
- console.log('Final labels:', labelsToSet);
-
- await github.rest.issues.setLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- labels: labelsToSet
- });
diff --git a/.github/workflows/mono-package-split.yml b/.github/workflows/mono-package-split.yml
deleted file mode 100644
index 23bc29d1..00000000
--- a/.github/workflows/mono-package-split.yml
+++ /dev/null
@@ -1,94 +0,0 @@
-name: 'Split ASU Packages'
-
-# Run action only on code deployments with a tag that matches the regex.
-# This will make it so that this only runs when we create a release.
-on:
- push:
- # Sync on pushes to the main branch OR tagged releases.
- branches:
- - main
- tags:
- # Sync on tags that include a version number with or without a leading 'v'.
- - 'v[0-9]+.[0-9]+.[0-9]+'
- - '[0-9]+.[0-9]+.[0-9]+'
- # Allow us to manually trigger the workflow
- workflow_dispatch:
-
-jobs:
- packages_split:
- # Limit this workflow to a specific repo. This will ensure that
- # it only runs on the webspark-mirror repo, and not any downstream
- # repositories that will also include this file.
- if: github.repository == 'asuwebplatforms/webspark-mirror'
- runs-on: ubuntu-latest
-
- strategy:
- fail-fast: false
- matrix:
- # define package to repository map
- package:
- - local_path: docroot/profiles/contrib/webspark/modules/asu_brand
- split_repository: asu_brand
- - local_path: docroot/profiles/contrib/webspark/modules/asu_user
- split_repository: asu_user
- - local_path: docroot/profiles/contrib/webspark/modules/asu_react_core
- split_repository: asu_react_core
- - local_path: docroot/profiles/contrib/webspark/modules/asu_react_integration
- split_repository: asu_react_integration
- - local_path: docroot/profiles/contrib/webspark/modules/asu_config_utility
- split_repository: asu_config_utility
- - local_path: docroot/profiles/contrib/webspark
- split_repository: webspark
-
- steps:
- - uses: actions/checkout@v4
- # Utilize a Github App to generate a temporary token for access to the various repositories.
- # This allows us to avoid hard coding a personal access token.
- # Inspired by https://devopsjournal.io/blog/2022/01/03/GitHub-Tokens,
- # but we are using a newer, better, official github action instead of
- # the community contributed one used in the blog post.
- - uses: actions/create-github-app-token@v1
- id: app-token
- with:
- app-id: ${{ vars.MIRROR_APP_ID }}
- private-key: ${{ secrets.WEBSPARK_MIRROR_SECRET }}
- owner: ${{ github.repository_owner }}
- # This app allows us to select all of the repositories we will be adding
- # our access permissions to via the app-token. Just list them below
- # in a comma separated string (without any spaces).
- repositories: 'asu_user,asu_brand,asu_react_core,asu_react_integration,asu_config_utility,webspark'
-
- # The danharrin/monorepo-split-github-action below is the tool that we are using
- # to sync the contents of the modules into their respective standalone,
- # read-only github repositories. This may seem awkward, but it's because
- # Packagist requires each repo to be standalone, unlike what we are able
- # to do with JavaScript where the monorepo splits out via tagging and
- # does not require "shadow" repositories.
- # See https://blog.logrocket.com/hosting-all-your-php-packages-together-in-a-monorepo
-
- # no tag
- -
- if: "!startsWith(github.ref, 'refs/tags/')"
- uses: danharrin/monorepo-split-github-action@v2.3.0
- env:
- GITHUB_TOKEN: x-access-token:${{ steps.app-token.outputs.token }}
- with:
- package_directory: '${{ matrix.package.local_path }}'
- repository_organization: ASUWebPlatforms
- repository_name: '${{ matrix.package.split_repository }}'
- user_name: ws2-release-bot
- user_email: ws2-release-bot@asu.edu
-
- # with tag
- -
- if: "startsWith(github.ref, 'refs/tags/')"
- uses: danharrin/monorepo-split-github-action@v2.3.0
- env:
- GITHUB_TOKEN: x-access-token:${{ steps.app-token.outputs.token }}
- with:
- tag: ${GITHUB_REF#refs/tags/}
- package_directory: '${{ matrix.package.local_path }}'
- repository_organization: ASUWebPlatforms
- repository_name: '${{ matrix.package.split_repository }}'
- user_name: ws2-release-bot
- user_email: ws2-release-bot@asu.edu
diff --git a/README.md b/README.md
index 197ca73f..2f0335c8 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@ Sites running the Webspark build.
[Local Development](#local-development) •
[Code Quality Tools](#code-quality-tools) •
+[Composer Package Registry](#composer-package-registry) •
[Resources](#resources)
@@ -109,6 +110,84 @@ Use the `--help` flag to see available options for each command. For example:
+## Composer Package Registry
+
+This repo doubles as a static [Composer repository](https://getcomposer.org/doc/05-repositories.md#composer)
+for some of the Webspark packages it contains. Selected subdirectories are packaged as
+zip artifacts, stored as GitHub Release assets on this repo, and advertised via
+a `packages.json` served from GitHub Pages — a simple, [Satis](https://composer.github.io/satis) alternative
+custom-built for ASU Webspark.
+
+Browse the published packages at
+[https://asuwebplatforms.github.io/webspark-composer-test](https://asuwebplatforms.github.io/webspark-composer-test).
+
+### Consumer usage (test)
+
+Add the repository to your project's `composer.json`:
+
+```json
+{
+ "repositories": [
+ { "type": "composer", "url": "https://asuwebplatforms.github.io/webspark-composer-test/" }
+ ]
+}
+```
+
+Then require packages as normal:
+
+```bash
+composer require asuwebplatforms/asu_brand
+```
+
+Notes for consumers:
+
+- The repository must remain **public** for unauthenticated `composer install`.
+- Branch builds are published as the `dev-main` version
+ (`composer require asuwebplatforms/asu_brand:dev-main`).
+- Release builds are published under the tag's version
+ (`composer require asuwebplatforms/asu_brand:0.0.5`).
+
+### How it works
+
+This all runs in **one repo** — there is no separate registry repo, and no
+cross-repo token. The single workflow
+`.github/workflows/composer-packages-publish.yml` does everything using the
+built-in `GITHUB_TOKEN`:
+
+1. **`ensure-release`** — on a tag push (or `dev-main` for a branch build),
+ creates the GitHub Release that will hold this version's assets.
+2. **`publish`** (matrix) — zips each selected package subdirectory with
+ `scripts/package.sh` and uploads it as a Release asset, then verifies the
+ asset downloads and its checksum matches.
+3. **`rebuild`** — merges the new version records into the committed
+ `packages.json` accumulator with `scripts/merge-packages-json.sh`, commits
+ it back to `main` (marked `[skip ci]`), and deploys `packages.json` +
+ `index.html` to GitHub Pages.
+
+`packages.json` is an **append-only accumulator of record** — it is committed
+to the repo and only ever has version entries added or replaced. Removing a
+package from the publish matrix stops new versions being added; it does **not**
+remove previously published entries.
+
+`scripts/package.sh` archives exactly the files **git tracks** (via
+`git ls-files`), so `.gitignore` is honored precisely — including build
+artifacts force-added under `node_modules`, which must ship with the package.
+
+### Registry files
+
+- `packages.json` — the accumulator of record (auto-generated; do not hand-edit).
+- `index.html` — human-readable browse page served alongside `packages.json`.
+- `scripts/package.sh` — zips a subdirectory + emits its metadata record.
+- `scripts/merge-packages-json.sh` — merges new version records into `packages.json`.
+- `.github/workflows/composer-packages-publish.yml` — the combined publish + Pages deploy pipeline.
+
+### Promoting to production
+
+The workflow's `REPO`, `BASE_URL`, and `PAGES_URL` env values are the single
+flip-point: change them to the production repo (`webspark-mirror`) and its Pages
+URL. Production will also be public, so the built-in `GITHUB_TOKEN` model
+carries over unchanged — no PAT or GitHub App required.
+
## Resources
- [DDEV setup for Acquia](https://docs.google.com/document/d/1R-wFpJnxUmQJ35bbFEhtoXF4YZXkHe6ZVVy8oBUn9kQ/edit?usp=sharing)
diff --git a/index.html b/index.html
new file mode 100644
index 00000000..5672d66e
--- /dev/null
+++ b/index.html
@@ -0,0 +1,215 @@
+
+
+
+
+
+ ASU Webspark Composer Packages
+
+
+
+
+
+
+
+ Add this repository
+ Add the following to your project's composer.json, then require packages as normal:
+ {
+ "repositories": [
+ { "type": "composer", "url": "https://asuwebplatforms.github.io/webspark-composer-test/" }
+ ]
+}
+ Require a package
+ composer require asuwebplatforms/asu_brand
+
+
+
+
+
+
+
+
+
+
diff --git a/packages.json b/packages.json
new file mode 100644
index 00000000..64cfead5
--- /dev/null
+++ b/packages.json
@@ -0,0 +1,448 @@
+{
+ "packages": {
+ "asuwebplatforms/asu_brand": {
+ "dev-main": {
+ "name": "asuwebplatforms/asu_brand",
+ "version": "dev-main",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_react_core": "^2.14",
+ "asuwebplatforms/asu_react_integration": "^2.14",
+ "asuwebplatforms/asu_user": "^2.14"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/dev-main/asu_brand-dev-main.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "4deb0f3cd7d8d1e6491cbb63a020d4b15a4f1d8a"
+ }
+ },
+ "0.0.4": {
+ "name": "asuwebplatforms/asu_brand",
+ "version": "0.0.4",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_react_core": "^2.14",
+ "asuwebplatforms/asu_react_integration": "^2.14",
+ "asuwebplatforms/asu_user": "^2.14"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.4/asu_brand-0.0.4.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "a17e6ee724514126d1662adb69c723c5df88dbec"
+ }
+ },
+ "0.0.5": {
+ "name": "asuwebplatforms/asu_brand",
+ "version": "0.0.5",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_react_core": "^2.14",
+ "asuwebplatforms/asu_react_integration": "^2.14",
+ "asuwebplatforms/asu_user": "^2.14"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.5/asu_brand-0.0.5.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "c00bc1b8b8a179d11c81037fefcd94c502f61e1c"
+ }
+ }
+ },
+ "asuwebplatforms/asu_config_utility": {
+ "dev-main": {
+ "name": "asuwebplatforms/asu_config_utility",
+ "version": "dev-main",
+ "type": "drupal-module",
+ "require": {
+ "drupal/config_update": "2.0.0-alpha3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/dev-main/asu_config_utility-dev-main.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "a711e282342881a678835acf9751a82dc9707772"
+ }
+ },
+ "0.0.4": {
+ "name": "asuwebplatforms/asu_config_utility",
+ "version": "0.0.4",
+ "type": "drupal-module",
+ "require": {
+ "drupal/config_update": "2.0.0-alpha3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.4/asu_config_utility-0.0.4.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "e30e37fd53f200867afbbb5dd2e1f73dc9511534"
+ }
+ },
+ "0.0.5": {
+ "name": "asuwebplatforms/asu_config_utility",
+ "version": "0.0.5",
+ "type": "drupal-module",
+ "require": {
+ "drupal/config_update": "2.0.0-alpha3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.5/asu_config_utility-0.0.5.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "aba816b504ab476c7c205f5248d1a314464e3282"
+ }
+ }
+ },
+ "asuwebplatforms/asu_react_core": {
+ "dev-main": {
+ "name": "asuwebplatforms/asu_react_core",
+ "version": "dev-main",
+ "type": "drupal-module",
+ "require": {
+ "drupal/paragraphs": "^1.17.0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/dev-main/asu_react_core-dev-main.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "0c4fb6930537be79f4c686a6427dcd24e701f2e8"
+ }
+ },
+ "0.0.4": {
+ "name": "asuwebplatforms/asu_react_core",
+ "version": "0.0.4",
+ "type": "drupal-module",
+ "require": {
+ "drupal/paragraphs": "^1.17.0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.4/asu_react_core-0.0.4.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "1cf83505d39801441de341a6ac89a9cb91303625"
+ }
+ },
+ "0.0.5": {
+ "name": "asuwebplatforms/asu_react_core",
+ "version": "0.0.5",
+ "type": "drupal-module",
+ "require": {
+ "drupal/paragraphs": "^1.17.0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.5/asu_react_core-0.0.5.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "9a3f9de72b785470602c372dc44ed59140f29cb5"
+ }
+ }
+ },
+ "asuwebplatforms/asu_react_integration": {
+ "dev-main": {
+ "name": "asuwebplatforms/asu_react_integration",
+ "version": "dev-main",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_react_core": "^2.14"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/dev-main/asu_react_integration-dev-main.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "c63137fbcfb6721726e745490cd105bf9284ec16"
+ }
+ },
+ "0.0.4": {
+ "name": "asuwebplatforms/asu_react_integration",
+ "version": "0.0.4",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_react_core": "^2.14"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.4/asu_react_integration-0.0.4.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "80711053296f68a3bc08a106915723f4b589dfe5"
+ }
+ },
+ "0.0.5": {
+ "name": "asuwebplatforms/asu_react_integration",
+ "version": "0.0.5",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_react_core": "^2.14"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.5/asu_react_integration-0.0.5.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "d89789639036ab553508627bb28484f5d35812f5"
+ }
+ }
+ },
+ "asuwebplatforms/asu_user": {
+ "dev-main": {
+ "name": "asuwebplatforms/asu_user",
+ "version": "dev-main",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_config_utility": "^2.14",
+ "drupal/cas": "^2.3.2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/dev-main/asu_user-dev-main.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "984876e08ea610d5e114e88ca7af6c98d29a24f4"
+ }
+ },
+ "0.0.4": {
+ "name": "asuwebplatforms/asu_user",
+ "version": "0.0.4",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_config_utility": "^2.14",
+ "drupal/cas": "^2.3.2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.4/asu_user-0.0.4.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "9a29210836cc7f25ad44161712c285bf3468af05"
+ }
+ },
+ "0.0.5": {
+ "name": "asuwebplatforms/asu_user",
+ "version": "0.0.5",
+ "type": "drupal-module",
+ "require": {
+ "asuwebplatforms/asu_config_utility": "^2.14",
+ "drupal/cas": "^2.3.2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.5/asu_user-0.0.5.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "414480f8f02302420bb2685171f4041b3ce36d02"
+ }
+ }
+ },
+ "asuwebplatforms/webspark": {
+ "dev-main": {
+ "name": "asuwebplatforms/webspark",
+ "version": "dev-main",
+ "type": "drupal-profile",
+ "require": {
+ "asuwebplatforms/asu_governance": "1.2.1",
+ "cweagans/composer-patches": "1.7.3",
+ "drupal/acquia_purge": "1.5.0",
+ "drupal/admin_toolbar": "3.6.1",
+ "drupal/allowed_formats": "3.0.1",
+ "drupal/anchor_link": "3.0.3",
+ "drupal/block_field": "1.0.0-rc4",
+ "drupal/captcha": "2.0.10",
+ "drupal/cas": "2.3.2",
+ "drupal/ckeditor": "1.0.2",
+ "drupal/ckeditor_responsive_plugin": "2.1.0-beta1",
+ "drupal/components": "3.2.0",
+ "drupal/config_readonly": "1.0.0-beta5",
+ "drupal/config_update": "2.0.0-alpha3",
+ "drupal/core-composer-scaffold": "10.5.11",
+ "drupal/core-recommended": "10.5.11",
+ "drupal/crop": "2.3.0",
+ "drupal/ctools": "4.1.0",
+ "drupal/current_page_crumb": "1.5.0",
+ "drupal/decorative_image_widget": "1.0.1",
+ "drupal/devel": "5.4.0",
+ "drupal/devel_kint_extras": "1.1.0",
+ "drupal/editor_advanced_link": "2.3.4",
+ "drupal/editoria11y": "2.2.18",
+ "drupal/environment_indicator": "4.0.14",
+ "drupal/field_group": "3.6.0",
+ "drupal/field_menu": "2.2.0",
+ "drupal/field_states_ui": "3.0.0",
+ "drupal/fontawesome": "3.0.0",
+ "drupal/image_widget_crop": "2.4.0",
+ "drupal/imagemagick": "4.0.2",
+ "drupal/layout_builder_component_attributes": "2.2.0",
+ "drupal/layout_builder_restrictions": "2.19.0",
+ "drupal/layout_builder_usage_reports": "1.0.2",
+ "drupal/layout_section_classes": "1.5.0",
+ "drupal/linkit": "7.0.15",
+ "drupal/maxlength": "2.1.2",
+ "drupal/media_library_form_element": "2.1.0",
+ "drupal/metatag": "2.2.0",
+ "drupal/paragraphs": "1.17.0",
+ "drupal/pathauto": "1.13.0",
+ "drupal/radix": "5.0.11",
+ "drupal/redirect": "1.9.0",
+ "drupal/robotstxt": "1.5.0",
+ "drupal/schema_metatag": "3.0.1",
+ "drupal/seckit": "2.0.3",
+ "drupal/select2": "1.15.0",
+ "drupal/simple_sitemap": "4.2.3",
+ "drupal/smtp": "1.2.0",
+ "drupal/webform": "6.2.3",
+ "drush/drush": "12.5.3",
+ "fontawesome/fontawesome": "*",
+ "northernco/ckeditor5-anchor-drupal": "*",
+ "npm-asset/fonticonpicker--fonticonpicker": "3.1.1",
+ "npm-asset/select2": "4.1.0-RC.0",
+ "oomphinc/composer-installers-extender": "2.0.1",
+ "php": ">=8.3.0",
+ "zaporylie/composer-drupal-optimizations": "1.2.0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/dev-main/webspark-dev-main.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "403e88abee960178738831dc902707f592918d72"
+ }
+ },
+ "0.0.4": {
+ "name": "asuwebplatforms/webspark",
+ "version": "0.0.4",
+ "type": "drupal-profile",
+ "require": {
+ "asuwebplatforms/asu_governance": "1.2.1",
+ "cweagans/composer-patches": "1.7.3",
+ "drupal/acquia_purge": "1.5.0",
+ "drupal/admin_toolbar": "3.6.1",
+ "drupal/allowed_formats": "3.0.1",
+ "drupal/anchor_link": "3.0.3",
+ "drupal/block_field": "1.0.0-rc4",
+ "drupal/captcha": "2.0.10",
+ "drupal/cas": "2.3.2",
+ "drupal/ckeditor": "1.0.2",
+ "drupal/ckeditor_responsive_plugin": "2.1.0-beta1",
+ "drupal/components": "3.2.0",
+ "drupal/config_readonly": "1.0.0-beta5",
+ "drupal/config_update": "2.0.0-alpha3",
+ "drupal/core-composer-scaffold": "10.5.11",
+ "drupal/core-recommended": "10.5.11",
+ "drupal/crop": "2.3.0",
+ "drupal/ctools": "4.1.0",
+ "drupal/current_page_crumb": "1.5.0",
+ "drupal/decorative_image_widget": "1.0.1",
+ "drupal/devel": "5.4.0",
+ "drupal/devel_kint_extras": "1.1.0",
+ "drupal/editor_advanced_link": "2.3.4",
+ "drupal/editoria11y": "2.2.18",
+ "drupal/environment_indicator": "4.0.14",
+ "drupal/field_group": "3.6.0",
+ "drupal/field_menu": "2.2.0",
+ "drupal/field_states_ui": "3.0.0",
+ "drupal/fontawesome": "3.0.0",
+ "drupal/image_widget_crop": "2.4.0",
+ "drupal/imagemagick": "4.0.2",
+ "drupal/layout_builder_component_attributes": "2.2.0",
+ "drupal/layout_builder_restrictions": "2.19.0",
+ "drupal/layout_builder_usage_reports": "1.0.2",
+ "drupal/layout_section_classes": "1.5.0",
+ "drupal/linkit": "7.0.15",
+ "drupal/maxlength": "2.1.2",
+ "drupal/media_library_form_element": "2.1.0",
+ "drupal/metatag": "2.2.0",
+ "drupal/paragraphs": "1.17.0",
+ "drupal/pathauto": "1.13.0",
+ "drupal/radix": "5.0.11",
+ "drupal/redirect": "1.9.0",
+ "drupal/robotstxt": "1.5.0",
+ "drupal/schema_metatag": "3.0.1",
+ "drupal/seckit": "2.0.3",
+ "drupal/select2": "1.15.0",
+ "drupal/simple_sitemap": "4.2.3",
+ "drupal/smtp": "1.2.0",
+ "drupal/webform": "6.2.3",
+ "drush/drush": "12.5.3",
+ "fontawesome/fontawesome": "*",
+ "northernco/ckeditor5-anchor-drupal": "*",
+ "npm-asset/fonticonpicker--fonticonpicker": "3.1.1",
+ "npm-asset/select2": "4.1.0-RC.0",
+ "oomphinc/composer-installers-extender": "2.0.1",
+ "php": ">=8.3.0",
+ "zaporylie/composer-drupal-optimizations": "1.2.0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.4/webspark-0.0.4.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "df0bde83143a46bf3fad52962292adf3a4a508ca"
+ }
+ },
+ "0.0.5": {
+ "name": "asuwebplatforms/webspark",
+ "version": "0.0.5",
+ "type": "drupal-profile",
+ "require": {
+ "asuwebplatforms/asu_governance": "1.2.1",
+ "cweagans/composer-patches": "1.7.3",
+ "drupal/acquia_purge": "1.5.0",
+ "drupal/admin_toolbar": "3.6.1",
+ "drupal/allowed_formats": "3.0.1",
+ "drupal/anchor_link": "3.0.3",
+ "drupal/block_field": "1.0.0-rc4",
+ "drupal/captcha": "2.0.10",
+ "drupal/cas": "2.3.2",
+ "drupal/ckeditor": "1.0.2",
+ "drupal/ckeditor_responsive_plugin": "2.1.0-beta1",
+ "drupal/components": "3.2.0",
+ "drupal/config_readonly": "1.0.0-beta5",
+ "drupal/config_update": "2.0.0-alpha3",
+ "drupal/core-composer-scaffold": "10.5.11",
+ "drupal/core-recommended": "10.5.11",
+ "drupal/crop": "2.3.0",
+ "drupal/ctools": "4.1.0",
+ "drupal/current_page_crumb": "1.5.0",
+ "drupal/decorative_image_widget": "1.0.1",
+ "drupal/devel": "5.4.0",
+ "drupal/devel_kint_extras": "1.1.0",
+ "drupal/editor_advanced_link": "2.3.4",
+ "drupal/editoria11y": "2.2.18",
+ "drupal/environment_indicator": "4.0.14",
+ "drupal/field_group": "3.6.0",
+ "drupal/field_menu": "2.2.0",
+ "drupal/field_states_ui": "3.0.0",
+ "drupal/fontawesome": "3.0.0",
+ "drupal/image_widget_crop": "2.4.0",
+ "drupal/imagemagick": "4.0.2",
+ "drupal/layout_builder_component_attributes": "2.2.0",
+ "drupal/layout_builder_restrictions": "2.19.0",
+ "drupal/layout_builder_usage_reports": "1.0.2",
+ "drupal/layout_section_classes": "1.5.0",
+ "drupal/linkit": "7.0.15",
+ "drupal/maxlength": "2.1.2",
+ "drupal/media_library_form_element": "2.1.0",
+ "drupal/metatag": "2.2.0",
+ "drupal/paragraphs": "1.17.0",
+ "drupal/pathauto": "1.13.0",
+ "drupal/radix": "5.0.11",
+ "drupal/redirect": "1.9.0",
+ "drupal/robotstxt": "1.5.0",
+ "drupal/schema_metatag": "3.0.1",
+ "drupal/seckit": "2.0.3",
+ "drupal/select2": "1.15.0",
+ "drupal/simple_sitemap": "4.2.3",
+ "drupal/smtp": "1.2.0",
+ "drupal/webform": "6.2.3",
+ "drush/drush": "12.5.3",
+ "fontawesome/fontawesome": "*",
+ "northernco/ckeditor5-anchor-drupal": "*",
+ "npm-asset/fonticonpicker--fonticonpicker": "3.1.1",
+ "npm-asset/select2": "4.1.0-RC.0",
+ "oomphinc/composer-installers-extender": "2.0.1",
+ "php": ">=8.3.0",
+ "zaporylie/composer-drupal-optimizations": "1.2.0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download/0.0.5/webspark-0.0.5.zip",
+ "reference": "33238d9a808724adc307ee9049ca91c848b97ee3",
+ "shasum": "a082ecf399244e3da54ba7563ed2ee1f5b0db634"
+ }
+ }
+ }
+ }
+}
diff --git a/scripts/merge-packages-json.sh b/scripts/merge-packages-json.sh
new file mode 100755
index 00000000..e747d607
--- /dev/null
+++ b/scripts/merge-packages-json.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+#
+# merge-packages-json.sh
+#
+# Merge one or more package metadata records into the committed packages.json
+# accumulator. Existing versions are preserved; only the named name->version
+# entries in the metadata are added or replaced.
+#
+# This is the only logic that builds packages.json. There is no manifest:
+# every record is derived from a package's own composer.json at build time
+# (see webspark-composer-test scripts/package.sh).
+#
+# Usage:
+# merge-packages-json.sh
+#
+# Path to the committed accumulator (created if missing).
+# JSON array of records produced by package.sh, e.g.
+# [ { "name": "...", "version": "...", "type": "...",
+# "require": {...}, "filename": "...", "tag": "...",
+# "reference": "...", "shasum": "..." }, ... ]
+# Release download base, e.g.
+# https://github.com/ASUWebPlatforms/webspark-composer-test/releases/download
+#
+set -euo pipefail
+
+PACKAGES_JSON="${1:?path to packages.json required}"
+METADATA="${2:?path to metadata.json required}"
+BASE_URL="${3:?release download base url required}"
+
+command -v jq >/dev/null 2>&1 || { echo "::error::jq is required" >&2; exit 1; }
+[ -f "$METADATA" ] || { echo "::error::metadata file not found: $METADATA" >&2; exit 1; }
+
+# Ensure the accumulator exists and has the expected shape.
+if [ ! -f "$PACKAGES_JSON" ]; then
+ echo '{"packages":{}}' > "$PACKAGES_JSON"
+fi
+
+# Validate inputs are JSON before we touch anything.
+jq empty "$PACKAGES_JSON"
+jq empty "$METADATA"
+
+tmp="$(mktemp)"
+trap 'rm -f "$tmp"' EXIT
+
+jq \
+ --arg base "$BASE_URL" \
+ --slurpfile recs "$METADATA" \
+ '
+ # Start from the existing accumulator, fold each record in.
+ reduce $recs[0][] as $r (.;
+ .packages[$r.name][$r.version] = {
+ name: $r.name,
+ version: $r.version,
+ type: $r.type,
+ require: ($r.require // {}),
+ dist: {
+ type: "zip",
+ url: ($base + "/" + $r.tag + "/" + $r.filename),
+ reference: $r.reference,
+ shasum: $r.shasum
+ }
+ }
+ )
+ ' "$PACKAGES_JSON" > "$tmp"
+
+# Final shape check, then atomically replace.
+jq empty "$tmp"
+mv "$tmp" "$PACKAGES_JSON"
+trap - EXIT
+
+echo "Merged $(jq '.[] | .name' -r "$METADATA" | wc -l | tr -d ' ') record(s) into $PACKAGES_JSON"