ci(catalog): separate catalog publishing from release deployment
8f2b8fe parent: f5f0f7d modified
.github/workflows/deploy-bunny.yml +5 -64 | @@ -4,17 +4,6 @@ on: | ||
| 4 | 4 | push: |
| 5 | 5 | tags: ["v*", "pre-v*"] |
| 6 | 6 | workflow_dispatch: |
| 7 | - inputs: | |
| 8 | - refresh_catalog: | |
| 9 | - description: Rebuild and publish the IDFM catalog | |
| 10 | - required: true | |
| 11 | - default: false | |
| 12 | - type: boolean | |
| 13 | - force_catalog_upload: | |
| 14 | - description: Upload a rebuilt catalog even when its IDFM source revision is already hosted | |
| 15 | - required: true | |
| 16 | - default: false | |
| 17 | - type: boolean | |
| 18 | 7 | |
| 19 | 8 | permissions: |
| 20 | 9 | contents: read |
| @@ -117,7 +106,7 @@ jobs: | ||
| 117 | 106 | deploy: |
| 118 | 107 | name: Build and deploy configuration site |
| 119 | 108 | needs: test |
| 120 | - if: ${{ startsWith(github.ref_name, 'v') || (github.event_name == 'workflow_dispatch' && inputs.refresh_catalog) }} | |
| 109 | + if: ${{ startsWith(github.ref_name, 'v') }} | |
| 121 | 110 | runs-on: ubuntu-latest |
| 122 | 111 | environment: production |
| 123 | 112 | steps: |
| @@ -130,42 +119,8 @@ jobs: | ||
| 130 | 119 | |
| 131 | 120 | - run: npm ci |
| 132 | 121 | |
| 133 | - - name: Refresh production catalog | |
| 134 | - if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }} | |
| 135 | - env: | |
| 136 | - IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }} | |
| 137 | - run: npm run catalog:refresh | |
| 138 | - | |
| 139 | - - name: Plan catalog upload | |
| 140 | - id: catalog | |
| 141 | - env: | |
| 142 | - CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} | |
| 143 | - EVENT_NAME: ${{ github.event_name }} | |
| 144 | - FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }} | |
| 145 | - run: | | |
| 146 | - set -euo pipefail | |
| 147 | - if [[ "$EVENT_NAME" != workflow_dispatch ]]; then | |
| 148 | - include_catalog=false | |
| 149 | - reason="release tags update the configuration page only" | |
| 150 | - elif [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then | |
| 151 | - include_catalog=true | |
| 152 | - reason="catalog upload was forced" | |
| 153 | - else | |
| 154 | - include_catalog="$(node scripts/plan-catalog-upload.mjs)" | |
| 155 | - reason="catalog source-revision comparison completed" | |
| 156 | - fi | |
| 157 | - echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT" | |
| 158 | - echo "$reason: include_catalog=$include_catalog" | |
| 159 | - | |
| 160 | - - name: Build static configuration site | |
| 161 | - env: | |
| 162 | - INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }} | |
| 163 | - run: | | |
| 164 | - if [[ "$INCLUDE_CATALOG" = true ]]; then | |
| 165 | - npm run build:config-site | |
| 166 | - else | |
| 167 | - npm run build:config-page | |
| 168 | - fi | |
| 122 | + - name: Build static configuration page | |
| 123 | + run: npm run build:config-page | |
| 169 | 124 | |
| 170 | 125 | - name: Back up mutable production files |
| 171 | 126 | id: backup |
| @@ -188,9 +143,6 @@ jobs: | ||
| 188 | 143 | |
| 189 | 144 | while IFS= read -r -d '' file; do |
| 190 | 145 | relative_path="${file#var/config-site/}" |
| 191 | - if [[ "$relative_path" =~ ^catalog/[a-f0-9]{64}/ ]]; then | |
| 192 | - continue | |
| 193 | - fi | |
| 194 | 146 | case "$relative_path" in |
| 195 | 147 | *[!A-Za-z0-9._/-]* | /* | *../*) |
| 196 | 148 | echo "Refusing unsafe backup path: $relative_path" >&2 |
| @@ -266,15 +218,10 @@ jobs: | ||
| 266 | 218 | |
| 267 | 219 | export endpoint BUNNY_STORAGE_PASSWORD |
| 268 | 220 | export -f upload_file |
| 269 | - find var/config-site -type f ! -path 'var/config-site/catalog/manifest.json' -print0 \ | |
| 221 | + find var/config-site -type f -print0 \ | |
| 270 | 222 | | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \ |
| 271 | 223 | bash -c 'upload_file "$1"' _ |
| 272 | 224 | |
| 273 | - # Publish the catalog pointer only after every immutable catalog file. | |
| 274 | - if [[ -f var/config-site/catalog/manifest.json ]]; then | |
| 275 | - upload_file var/config-site/catalog/manifest.json | |
| 276 | - fi | |
| 277 | - | |
| 278 | 225 | - name: Purge Bunny CDN cache |
| 279 | 226 | env: |
| 280 | 227 | BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} |
| @@ -292,15 +239,9 @@ jobs: | ||
| 292 | 239 | - name: Verify served static site |
| 293 | 240 | env: |
| 294 | 241 | CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} |
| 295 | - INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }} | |
| 296 | 242 | run: | |
| 297 | 243 | for attempt in 1 2 3; do |
| 298 | - if [[ "$INCLUDE_CATALOG" = true ]]; then | |
| 299 | - command=(npm run verify:config-site) | |
| 300 | - else | |
| 301 | - command=(npm run verify:config-site -- --page-only) | |
| 302 | - fi | |
| 303 | - if "${command[@]}"; then | |
| 244 | + if npm run verify:config-site -- --page-only; then | |
| 304 | 245 | exit 0 |
| 305 | 246 | fi |
| 306 | 247 | if [[ "$attempt" != 3 ]]; then sleep 10; fi |
| @@ -4,17 +4,6 @@ on: | |||
| 4 | push: | 4 | push: |
| 5 | tags: ["v*", "pre-v*"] | 5 | tags: ["v*", "pre-v*"] |
| 6 | workflow_dispatch: | 6 | workflow_dispatch: |
| 7 | - inputs: | ||
| 8 | - refresh_catalog: | ||
| 9 | - description: Rebuild and publish the IDFM catalog | ||
| 10 | - required: true | ||
| 11 | - default: false | ||
| 12 | - type: boolean | ||
| 13 | - force_catalog_upload: | ||
| 14 | - description: Upload a rebuilt catalog even when its IDFM source revision is already hosted | ||
| 15 | - required: true | ||
| 16 | - default: false | ||
| 17 | - type: boolean | ||
| 18 | 7 | ||
| 19 | permissions: | 8 | permissions: |
| 20 | contents: read | 9 | contents: read |
| @@ -117,7 +106,7 @@ jobs: | |||
| 117 | deploy: | 106 | deploy: |
| 118 | name: Build and deploy configuration site | 107 | name: Build and deploy configuration site |
| 119 | needs: test | 108 | needs: test |
| 120 | - if: ${{ startsWith(github.ref_name, 'v') || (github.event_name == 'workflow_dispatch' && inputs.refresh_catalog) }} | 109 | + if: ${{ startsWith(github.ref_name, 'v') }} |
| 121 | runs-on: ubuntu-latest | 110 | runs-on: ubuntu-latest |
| 122 | environment: production | 111 | environment: production |
| 123 | steps: | 112 | steps: |
| @@ -130,42 +119,8 @@ jobs: | |||
| 130 | 119 | ||
| 131 | - run: npm ci | 120 | - run: npm ci |
| 132 | 121 | ||
| 133 | - - name: Refresh production catalog | 122 | + - name: Build static configuration page |
| 134 | - if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }} | 123 | + run: npm run build:config-page |
| 135 | - env: | ||
| 136 | - IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }} | ||
| 137 | - run: npm run catalog:refresh | ||
| 138 | - | ||
| 139 | - - name: Plan catalog upload | ||
| 140 | - id: catalog | ||
| 141 | - env: | ||
| 142 | - CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} | ||
| 143 | - EVENT_NAME: ${{ github.event_name }} | ||
| 144 | - FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }} | ||
| 145 | - run: | | ||
| 146 | - set -euo pipefail | ||
| 147 | - if [[ "$EVENT_NAME" != workflow_dispatch ]]; then | ||
| 148 | - include_catalog=false | ||
| 149 | - reason="release tags update the configuration page only" | ||
| 150 | - elif [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then | ||
| 151 | - include_catalog=true | ||
| 152 | - reason="catalog upload was forced" | ||
| 153 | - else | ||
| 154 | - include_catalog="$(node scripts/plan-catalog-upload.mjs)" | ||
| 155 | - reason="catalog source-revision comparison completed" | ||
| 156 | - fi | ||
| 157 | - echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT" | ||
| 158 | - echo "$reason: include_catalog=$include_catalog" | ||
| 159 | - | ||
| 160 | - - name: Build static configuration site | ||
| 161 | - env: | ||
| 162 | - INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }} | ||
| 163 | - run: | | ||
| 164 | - if [[ "$INCLUDE_CATALOG" = true ]]; then | ||
| 165 | - npm run build:config-site | ||
| 166 | - else | ||
| 167 | - npm run build:config-page | ||
| 168 | - fi | ||
| 169 | 124 | ||
| 170 | - name: Back up mutable production files | 125 | - name: Back up mutable production files |
| 171 | id: backup | 126 | id: backup |
| @@ -188,9 +143,6 @@ jobs: | |||
| 188 | 143 | ||
| 189 | while IFS= read -r -d '' file; do | 144 | while IFS= read -r -d '' file; do |
| 190 | relative_path="${file#var/config-site/}" | 145 | relative_path="${file#var/config-site/}" |
| 191 | - if [[ "$relative_path" =~ ^catalog/[a-f0-9]{64}/ ]]; then | ||
| 192 | - continue | ||
| 193 | - fi | ||
| 194 | case "$relative_path" in | 146 | case "$relative_path" in |
| 195 | *[!A-Za-z0-9._/-]* | /* | *../*) | 147 | *[!A-Za-z0-9._/-]* | /* | *../*) |
| 196 | echo "Refusing unsafe backup path: $relative_path" >&2 | 148 | echo "Refusing unsafe backup path: $relative_path" >&2 |
| @@ -266,15 +218,10 @@ jobs: | |||
| 266 | 218 | ||
| 267 | export endpoint BUNNY_STORAGE_PASSWORD | 219 | export endpoint BUNNY_STORAGE_PASSWORD |
| 268 | export -f upload_file | 220 | export -f upload_file |
| 269 | - find var/config-site -type f ! -path 'var/config-site/catalog/manifest.json' -print0 \ | 221 | + find var/config-site -type f -print0 \ |
| 270 | | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \ | 222 | | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \ |
| 271 | bash -c 'upload_file "$1"' _ | 223 | bash -c 'upload_file "$1"' _ |
| 272 | 224 | ||
| 273 | - # Publish the catalog pointer only after every immutable catalog file. | ||
| 274 | - if [[ -f var/config-site/catalog/manifest.json ]]; then | ||
| 275 | - upload_file var/config-site/catalog/manifest.json | ||
| 276 | - fi | ||
| 277 | - | ||
| 278 | - name: Purge Bunny CDN cache | 225 | - name: Purge Bunny CDN cache |
| 279 | env: | 226 | env: |
| 280 | BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} | 227 | BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} |
| @@ -292,15 +239,9 @@ jobs: | |||
| 292 | - name: Verify served static site | 239 | - name: Verify served static site |
| 293 | env: | 240 | env: |
| 294 | CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} | 241 | CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} |
| 295 | - INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }} | ||
| 296 | run: | | 242 | run: | |
| 297 | for attempt in 1 2 3; do | 243 | for attempt in 1 2 3; do |
| 298 | - if [[ "$INCLUDE_CATALOG" = true ]]; then | 244 | + if npm run verify:config-site -- --page-only; then |
| 299 | - command=(npm run verify:config-site) | ||
| 300 | - else | ||
| 301 | - command=(npm run verify:config-site -- --page-only) | ||
| 302 | - fi | ||
| 303 | - if "${command[@]}"; then | ||
| 304 | exit 0 | 245 | exit 0 |
| 305 | fi | 246 | fi |
| 306 | if [[ "$attempt" != 3 ]]; then sleep 10; fi | 247 | if [[ "$attempt" != 3 ]]; then sleep 10; fi |
added
.github/workflows/rebuild-catalog.yml +263 -0 | new file mode 100644 | ||
| @@ -0,0 +1,263 @@ | ||
| 1 | +name: Rebuild and publish IDFM catalog | |
| 2 | + | |
| 3 | +on: | |
| 4 | + workflow_dispatch: | |
| 5 | + inputs: | |
| 6 | + force_catalog_upload: | |
| 7 | + description: Upload a rebuilt catalog even when its IDFM source revision is already hosted | |
| 8 | + required: true | |
| 9 | + default: false | |
| 10 | + type: boolean | |
| 11 | + | |
| 12 | +permissions: | |
| 13 | + contents: read | |
| 14 | + | |
| 15 | +concurrency: | |
| 16 | + group: deploy-bunny-production | |
| 17 | + # Keep catalog publication, page deployment, and rollback serialized. | |
| 18 | + cancel-in-progress: false | |
| 19 | + | |
| 20 | +jobs: | |
| 21 | + test: | |
| 22 | + name: Run quality checks | |
| 23 | + runs-on: ubuntu-latest | |
| 24 | + steps: | |
| 25 | + - uses: actions/checkout@v4 | |
| 26 | + | |
| 27 | + - uses: actions/setup-node@v4 | |
| 28 | + with: | |
| 29 | + node-version-file: .node-version | |
| 30 | + cache: npm | |
| 31 | + | |
| 32 | + - run: npm ci | |
| 33 | + | |
| 34 | + - run: npm test | |
| 35 | + | |
| 36 | + deploy: | |
| 37 | + name: Rebuild and publish catalog | |
| 38 | + needs: test | |
| 39 | + runs-on: ubuntu-latest | |
| 40 | + environment: production | |
| 41 | + steps: | |
| 42 | + - uses: actions/checkout@v4 | |
| 43 | + | |
| 44 | + - uses: actions/setup-node@v4 | |
| 45 | + with: | |
| 46 | + node-version-file: .node-version | |
| 47 | + cache: npm | |
| 48 | + | |
| 49 | + - run: npm ci | |
| 50 | + | |
| 51 | + - name: Refresh production catalog | |
| 52 | + env: | |
| 53 | + IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }} | |
| 54 | + run: npm run catalog:refresh | |
| 55 | + | |
| 56 | + - name: Plan catalog upload | |
| 57 | + id: catalog | |
| 58 | + env: | |
| 59 | + CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} | |
| 60 | + FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }} | |
| 61 | + run: | | |
| 62 | + set -euo pipefail | |
| 63 | + if [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then | |
| 64 | + include_catalog=true | |
| 65 | + reason="catalog upload was forced" | |
| 66 | + else | |
| 67 | + include_catalog="$(node scripts/plan-catalog-upload.mjs)" | |
| 68 | + reason="catalog source-revision comparison completed" | |
| 69 | + fi | |
| 70 | + echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT" | |
| 71 | + echo "$reason: include_catalog=$include_catalog" | |
| 72 | + | |
| 73 | + | |
| 74 | + - name: Back up mutable production files | |
| 75 | + id: backup | |
| 76 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | |
| 77 | + shell: bash | |
| 78 | + env: | |
| 79 | + BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} | |
| 80 | + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} | |
| 81 | + run: | | |
| 82 | + set -euo pipefail | |
| 83 | + | |
| 84 | + test -n "$BUNNY_STORAGE_ENDPOINT" | |
| 85 | + test -n "$BUNNY_STORAGE_PASSWORD" | |
| 86 | + endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | |
| 87 | + case "$endpoint" in | |
| 88 | + https://*) ;; | |
| 89 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | |
| 90 | + esac | |
| 91 | + mkdir -p var/deploy-backup/files | |
| 92 | + : > var/deploy-backup/missing | |
| 93 | + | |
| 94 | + while IFS= read -r -d '' file; do | |
| 95 | + relative_path="catalog/${file#var/catalog/static/}" | |
| 96 | + case "$relative_path" in | |
| 97 | + *[!A-Za-z0-9._/-]* | /* | *../*) | |
| 98 | + echo "Refusing unsafe backup path: $relative_path" >&2 | |
| 99 | + exit 1 | |
| 100 | + ;; | |
| 101 | + esac | |
| 102 | + | |
| 103 | + target="var/deploy-backup/files/$relative_path" | |
| 104 | + mkdir -p "$(dirname "$target")" | |
| 105 | + status="$(curl --silent --show-error \ | |
| 106 | + --output "$target" \ | |
| 107 | + --write-out '%{http_code}' \ | |
| 108 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | |
| 109 | + "$endpoint/$relative_path")" | |
| 110 | + case "$status" in | |
| 111 | + 200) ;; | |
| 112 | + 404) | |
| 113 | + rm -f "$target" | |
| 114 | + printf '%s\0' "$relative_path" >> var/deploy-backup/missing | |
| 115 | + ;; | |
| 116 | + *) | |
| 117 | + echo "Could not back up $relative_path: HTTP $status" >&2 | |
| 118 | + exit 1 | |
| 119 | + ;; | |
| 120 | + esac | |
| 121 | + done < <(find var/catalog/static -maxdepth 1 -type f -name manifest.json -print0) | |
| 122 | + | |
| 123 | + - name: Retain rollback bundle | |
| 124 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | |
| 125 | + uses: actions/upload-artifact@v4 | |
| 126 | + with: | |
| 127 | + name: bunny-rollback-${{ github.run_id }} | |
| 128 | + path: var/deploy-backup/ | |
| 129 | + if-no-files-found: error | |
| 130 | + retention-days: 30 | |
| 131 | + | |
| 132 | + - name: Upload catalog to Bunny Storage | |
| 133 | + id: upload | |
| 134 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | |
| 135 | + shell: bash | |
| 136 | + env: | |
| 137 | + BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} | |
| 138 | + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} | |
| 139 | + run: | | |
| 140 | + set -euo pipefail | |
| 141 | + | |
| 142 | + test -n "$BUNNY_STORAGE_ENDPOINT" | |
| 143 | + test -n "$BUNNY_STORAGE_PASSWORD" | |
| 144 | + endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | |
| 145 | + case "$endpoint" in | |
| 146 | + https://*) ;; | |
| 147 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | |
| 148 | + esac | |
| 149 | + | |
| 150 | + upload_file() { | |
| 151 | + local file="$1" | |
| 152 | + local relative_path="catalog/${file#var/catalog/static/}" | |
| 153 | + local mime_type | |
| 154 | + case "$relative_path" in | |
| 155 | + *[!A-Za-z0-9._/-]* | /* | *../*) | |
| 156 | + echo "Refusing unsafe deployment path: $relative_path" >&2 | |
| 157 | + exit 1 | |
| 158 | + ;; | |
| 159 | + esac | |
| 160 | + mime_type="$(file --brief --mime-type "$file")" | |
| 161 | + echo "Uploading $relative_path" | |
| 162 | + curl --fail --silent --show-error \ | |
| 163 | + --retry 3 --retry-all-errors \ | |
| 164 | + --request PUT \ | |
| 165 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | |
| 166 | + --header "Content-Type: $mime_type" \ | |
| 167 | + --upload-file "$file" \ | |
| 168 | + "$endpoint/$relative_path" | |
| 169 | + } | |
| 170 | + | |
| 171 | + export endpoint BUNNY_STORAGE_PASSWORD | |
| 172 | + export -f upload_file | |
| 173 | + find var/catalog/static -type f ! -path 'var/catalog/static/manifest.json' -print0 \ | |
| 174 | + | xargs -0 --no-run-if-empty --max-args=1 --max-procs=75 \ | |
| 175 | + bash -c 'upload_file "$1"' _ | |
| 176 | + | |
| 177 | + # Publish the catalog pointer only after every immutable catalog file. | |
| 178 | + upload_file var/catalog/static/manifest.json | |
| 179 | + | |
| 180 | + - name: Purge Bunny CDN cache | |
| 181 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | |
| 182 | + env: | |
| 183 | + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} | |
| 184 | + BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }} | |
| 185 | + run: | | |
| 186 | + set -euo pipefail | |
| 187 | + test -n "$BUNNY_API_KEY" | |
| 188 | + test -n "$BUNNY_PULL_ZONE_ID" | |
| 189 | + curl --fail --silent --show-error \ | |
| 190 | + --retry 3 --retry-all-errors \ | |
| 191 | + --request POST \ | |
| 192 | + --header "AccessKey: $BUNNY_API_KEY" \ | |
| 193 | + "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache" | |
| 194 | + | |
| 195 | + - name: Verify served catalog | |
| 196 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | |
| 197 | + env: | |
| 198 | + CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} | |
| 199 | + run: | | |
| 200 | + for attempt in 1 2 3; do | |
| 201 | + if npm run verify:config-site -- --catalog-only; then | |
| 202 | + exit 0 | |
| 203 | + fi | |
| 204 | + if [[ "$attempt" != 3 ]]; then sleep 10; fi | |
| 205 | + done | |
| 206 | + exit 1 | |
| 207 | + | |
| 208 | + - name: Restore previous mutable files after failure | |
| 209 | + if: ${{ failure() && steps.backup.outcome == 'success' }} | |
| 210 | + shell: bash | |
| 211 | + env: | |
| 212 | + BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} | |
| 213 | + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} | |
| 214 | + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} | |
| 215 | + BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }} | |
| 216 | + run: | | |
| 217 | + set -euo pipefail | |
| 218 | + | |
| 219 | + endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | |
| 220 | + case "$endpoint" in | |
| 221 | + https://*) ;; | |
| 222 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | |
| 223 | + esac | |
| 224 | + restore_file() { | |
| 225 | + local file="$1" | |
| 226 | + local relative_path="${file#var/deploy-backup/files/}" | |
| 227 | + local mime_type | |
| 228 | + mime_type="$(file --brief --mime-type "$file")" | |
| 229 | + echo "Restoring $relative_path" | |
| 230 | + curl --fail --silent --show-error \ | |
| 231 | + --request PUT \ | |
| 232 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | |
| 233 | + --header "Content-Type: $mime_type" \ | |
| 234 | + --upload-file "$file" \ | |
| 235 | + "$endpoint/$relative_path" | |
| 236 | + } | |
| 237 | + export endpoint BUNNY_STORAGE_PASSWORD | |
| 238 | + export -f restore_file | |
| 239 | + find var/deploy-backup/files -type f -print0 \ | |
| 240 | + | xargs -0 --no-run-if-empty --max-args=1 --max-procs=16 \ | |
| 241 | + bash -c 'restore_file "$1"' _ | |
| 242 | + | |
| 243 | + while IFS= read -r -d '' relative_path; do | |
| 244 | + echo "Removing newly introduced mutable file $relative_path" | |
| 245 | + status="$(curl --silent --show-error \ | |
| 246 | + --request DELETE \ | |
| 247 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | |
| 248 | + --output /dev/null \ | |
| 249 | + --write-out '%{http_code}' \ | |
| 250 | + "$endpoint/$relative_path")" | |
| 251 | + case "$status" in | |
| 252 | + 200 | 201 | 204 | 404) ;; | |
| 253 | + *) | |
| 254 | + echo "Could not remove $relative_path during rollback: HTTP $status" >&2 | |
| 255 | + exit 1 | |
| 256 | + ;; | |
| 257 | + esac | |
| 258 | + done < var/deploy-backup/missing | |
| 259 | + | |
| 260 | + curl --fail --silent --show-error \ | |
| 261 | + --request POST \ | |
| 262 | + --header "AccessKey: $BUNNY_API_KEY" \ | |
| 263 | + "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache" | |
| new file mode 100644 | |||
| @@ -0,0 +1,263 @@ | |||
| 1 | +name: Rebuild and publish IDFM catalog | ||
| 2 | + | ||
| 3 | +on: | ||
| 4 | + workflow_dispatch: | ||
| 5 | + inputs: | ||
| 6 | + force_catalog_upload: | ||
| 7 | + description: Upload a rebuilt catalog even when its IDFM source revision is already hosted | ||
| 8 | + required: true | ||
| 9 | + default: false | ||
| 10 | + type: boolean | ||
| 11 | + | ||
| 12 | +permissions: | ||
| 13 | + contents: read | ||
| 14 | + | ||
| 15 | +concurrency: | ||
| 16 | + group: deploy-bunny-production | ||
| 17 | + # Keep catalog publication, page deployment, and rollback serialized. | ||
| 18 | + cancel-in-progress: false | ||
| 19 | + | ||
| 20 | +jobs: | ||
| 21 | + test: | ||
| 22 | + name: Run quality checks | ||
| 23 | + runs-on: ubuntu-latest | ||
| 24 | + steps: | ||
| 25 | + - uses: actions/checkout@v4 | ||
| 26 | + | ||
| 27 | + - uses: actions/setup-node@v4 | ||
| 28 | + with: | ||
| 29 | + node-version-file: .node-version | ||
| 30 | + cache: npm | ||
| 31 | + | ||
| 32 | + - run: npm ci | ||
| 33 | + | ||
| 34 | + - run: npm test | ||
| 35 | + | ||
| 36 | + deploy: | ||
| 37 | + name: Rebuild and publish catalog | ||
| 38 | + needs: test | ||
| 39 | + runs-on: ubuntu-latest | ||
| 40 | + environment: production | ||
| 41 | + steps: | ||
| 42 | + - uses: actions/checkout@v4 | ||
| 43 | + | ||
| 44 | + - uses: actions/setup-node@v4 | ||
| 45 | + with: | ||
| 46 | + node-version-file: .node-version | ||
| 47 | + cache: npm | ||
| 48 | + | ||
| 49 | + - run: npm ci | ||
| 50 | + | ||
| 51 | + - name: Refresh production catalog | ||
| 52 | + env: | ||
| 53 | + IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }} | ||
| 54 | + run: npm run catalog:refresh | ||
| 55 | + | ||
| 56 | + - name: Plan catalog upload | ||
| 57 | + id: catalog | ||
| 58 | + env: | ||
| 59 | + CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} | ||
| 60 | + FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }} | ||
| 61 | + run: | | ||
| 62 | + set -euo pipefail | ||
| 63 | + if [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then | ||
| 64 | + include_catalog=true | ||
| 65 | + reason="catalog upload was forced" | ||
| 66 | + else | ||
| 67 | + include_catalog="$(node scripts/plan-catalog-upload.mjs)" | ||
| 68 | + reason="catalog source-revision comparison completed" | ||
| 69 | + fi | ||
| 70 | + echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT" | ||
| 71 | + echo "$reason: include_catalog=$include_catalog" | ||
| 72 | + | ||
| 73 | + | ||
| 74 | + - name: Back up mutable production files | ||
| 75 | + id: backup | ||
| 76 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | ||
| 77 | + shell: bash | ||
| 78 | + env: | ||
| 79 | + BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} | ||
| 80 | + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} | ||
| 81 | + run: | | ||
| 82 | + set -euo pipefail | ||
| 83 | + | ||
| 84 | + test -n "$BUNNY_STORAGE_ENDPOINT" | ||
| 85 | + test -n "$BUNNY_STORAGE_PASSWORD" | ||
| 86 | + endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | ||
| 87 | + case "$endpoint" in | ||
| 88 | + https://*) ;; | ||
| 89 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | ||
| 90 | + esac | ||
| 91 | + mkdir -p var/deploy-backup/files | ||
| 92 | + : > var/deploy-backup/missing | ||
| 93 | + | ||
| 94 | + while IFS= read -r -d '' file; do | ||
| 95 | + relative_path="catalog/${file#var/catalog/static/}" | ||
| 96 | + case "$relative_path" in | ||
| 97 | + *[!A-Za-z0-9._/-]* | /* | *../*) | ||
| 98 | + echo "Refusing unsafe backup path: $relative_path" >&2 | ||
| 99 | + exit 1 | ||
| 100 | + ;; | ||
| 101 | + esac | ||
| 102 | + | ||
| 103 | + target="var/deploy-backup/files/$relative_path" | ||
| 104 | + mkdir -p "$(dirname "$target")" | ||
| 105 | + status="$(curl --silent --show-error \ | ||
| 106 | + --output "$target" \ | ||
| 107 | + --write-out '%{http_code}' \ | ||
| 108 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | ||
| 109 | + "$endpoint/$relative_path")" | ||
| 110 | + case "$status" in | ||
| 111 | + 200) ;; | ||
| 112 | + 404) | ||
| 113 | + rm -f "$target" | ||
| 114 | + printf '%s\0' "$relative_path" >> var/deploy-backup/missing | ||
| 115 | + ;; | ||
| 116 | + *) | ||
| 117 | + echo "Could not back up $relative_path: HTTP $status" >&2 | ||
| 118 | + exit 1 | ||
| 119 | + ;; | ||
| 120 | + esac | ||
| 121 | + done < <(find var/catalog/static -maxdepth 1 -type f -name manifest.json -print0) | ||
| 122 | + | ||
| 123 | + - name: Retain rollback bundle | ||
| 124 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | ||
| 125 | + uses: actions/upload-artifact@v4 | ||
| 126 | + with: | ||
| 127 | + name: bunny-rollback-${{ github.run_id }} | ||
| 128 | + path: var/deploy-backup/ | ||
| 129 | + if-no-files-found: error | ||
| 130 | + retention-days: 30 | ||
| 131 | + | ||
| 132 | + - name: Upload catalog to Bunny Storage | ||
| 133 | + id: upload | ||
| 134 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | ||
| 135 | + shell: bash | ||
| 136 | + env: | ||
| 137 | + BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} | ||
| 138 | + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} | ||
| 139 | + run: | | ||
| 140 | + set -euo pipefail | ||
| 141 | + | ||
| 142 | + test -n "$BUNNY_STORAGE_ENDPOINT" | ||
| 143 | + test -n "$BUNNY_STORAGE_PASSWORD" | ||
| 144 | + endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | ||
| 145 | + case "$endpoint" in | ||
| 146 | + https://*) ;; | ||
| 147 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | ||
| 148 | + esac | ||
| 149 | + | ||
| 150 | + upload_file() { | ||
| 151 | + local file="$1" | ||
| 152 | + local relative_path="catalog/${file#var/catalog/static/}" | ||
| 153 | + local mime_type | ||
| 154 | + case "$relative_path" in | ||
| 155 | + *[!A-Za-z0-9._/-]* | /* | *../*) | ||
| 156 | + echo "Refusing unsafe deployment path: $relative_path" >&2 | ||
| 157 | + exit 1 | ||
| 158 | + ;; | ||
| 159 | + esac | ||
| 160 | + mime_type="$(file --brief --mime-type "$file")" | ||
| 161 | + echo "Uploading $relative_path" | ||
| 162 | + curl --fail --silent --show-error \ | ||
| 163 | + --retry 3 --retry-all-errors \ | ||
| 164 | + --request PUT \ | ||
| 165 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | ||
| 166 | + --header "Content-Type: $mime_type" \ | ||
| 167 | + --upload-file "$file" \ | ||
| 168 | + "$endpoint/$relative_path" | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + export endpoint BUNNY_STORAGE_PASSWORD | ||
| 172 | + export -f upload_file | ||
| 173 | + find var/catalog/static -type f ! -path 'var/catalog/static/manifest.json' -print0 \ | ||
| 174 | + | xargs -0 --no-run-if-empty --max-args=1 --max-procs=75 \ | ||
| 175 | + bash -c 'upload_file "$1"' _ | ||
| 176 | + | ||
| 177 | + # Publish the catalog pointer only after every immutable catalog file. | ||
| 178 | + upload_file var/catalog/static/manifest.json | ||
| 179 | + | ||
| 180 | + - name: Purge Bunny CDN cache | ||
| 181 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | ||
| 182 | + env: | ||
| 183 | + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} | ||
| 184 | + BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }} | ||
| 185 | + run: | | ||
| 186 | + set -euo pipefail | ||
| 187 | + test -n "$BUNNY_API_KEY" | ||
| 188 | + test -n "$BUNNY_PULL_ZONE_ID" | ||
| 189 | + curl --fail --silent --show-error \ | ||
| 190 | + --retry 3 --retry-all-errors \ | ||
| 191 | + --request POST \ | ||
| 192 | + --header "AccessKey: $BUNNY_API_KEY" \ | ||
| 193 | + "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache" | ||
| 194 | + | ||
| 195 | + - name: Verify served catalog | ||
| 196 | + if: ${{ steps.catalog.outputs.include_catalog == 'true' }} | ||
| 197 | + env: | ||
| 198 | + CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }} | ||
| 199 | + run: | | ||
| 200 | + for attempt in 1 2 3; do | ||
| 201 | + if npm run verify:config-site -- --catalog-only; then | ||
| 202 | + exit 0 | ||
| 203 | + fi | ||
| 204 | + if [[ "$attempt" != 3 ]]; then sleep 10; fi | ||
| 205 | + done | ||
| 206 | + exit 1 | ||
| 207 | + | ||
| 208 | + - name: Restore previous mutable files after failure | ||
| 209 | + if: ${{ failure() && steps.backup.outcome == 'success' }} | ||
| 210 | + shell: bash | ||
| 211 | + env: | ||
| 212 | + BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} | ||
| 213 | + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} | ||
| 214 | + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} | ||
| 215 | + BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }} | ||
| 216 | + run: | | ||
| 217 | + set -euo pipefail | ||
| 218 | + | ||
| 219 | + endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | ||
| 220 | + case "$endpoint" in | ||
| 221 | + https://*) ;; | ||
| 222 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | ||
| 223 | + esac | ||
| 224 | + restore_file() { | ||
| 225 | + local file="$1" | ||
| 226 | + local relative_path="${file#var/deploy-backup/files/}" | ||
| 227 | + local mime_type | ||
| 228 | + mime_type="$(file --brief --mime-type "$file")" | ||
| 229 | + echo "Restoring $relative_path" | ||
| 230 | + curl --fail --silent --show-error \ | ||
| 231 | + --request PUT \ | ||
| 232 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | ||
| 233 | + --header "Content-Type: $mime_type" \ | ||
| 234 | + --upload-file "$file" \ | ||
| 235 | + "$endpoint/$relative_path" | ||
| 236 | + } | ||
| 237 | + export endpoint BUNNY_STORAGE_PASSWORD | ||
| 238 | + export -f restore_file | ||
| 239 | + find var/deploy-backup/files -type f -print0 \ | ||
| 240 | + | xargs -0 --no-run-if-empty --max-args=1 --max-procs=16 \ | ||
| 241 | + bash -c 'restore_file "$1"' _ | ||
| 242 | + | ||
| 243 | + while IFS= read -r -d '' relative_path; do | ||
| 244 | + echo "Removing newly introduced mutable file $relative_path" | ||
| 245 | + status="$(curl --silent --show-error \ | ||
| 246 | + --request DELETE \ | ||
| 247 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | ||
| 248 | + --output /dev/null \ | ||
| 249 | + --write-out '%{http_code}' \ | ||
| 250 | + "$endpoint/$relative_path")" | ||
| 251 | + case "$status" in | ||
| 252 | + 200 | 201 | 204 | 404) ;; | ||
| 253 | + *) | ||
| 254 | + echo "Could not remove $relative_path during rollback: HTTP $status" >&2 | ||
| 255 | + exit 1 | ||
| 256 | + ;; | ||
| 257 | + esac | ||
| 258 | + done < var/deploy-backup/missing | ||
| 259 | + | ||
| 260 | + curl --fail --silent --show-error \ | ||
| 261 | + --request POST \ | ||
| 262 | + --header "AccessKey: $BUNNY_API_KEY" \ | ||
| 263 | + "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache" | ||
modified
.github/workflows/rollback-bunny.yml +7 -2 | @@ -35,7 +35,11 @@ jobs: | ||
| 35 | 35 | --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')" |
| 36 | 36 | IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata" |
| 37 | 37 | test "$status" = completed |
| 38 | - test "$workflow_path" = ".github/workflows/deploy-bunny.yml" | |
| 38 | + case "$workflow_path" in | |
| 39 | + .github/workflows/deploy-bunny.yml) ;; | |
| 40 | + .github/workflows/rebuild-catalog.yml) test "$event" = workflow_dispatch ;; | |
| 41 | + *) echo "Unsupported deployment workflow: $workflow_path" >&2; exit 1 ;; | |
| 42 | + esac | |
| 39 | 43 | test "$head_repository" = "$GH_REPO" |
| 40 | 44 | [[ "$event" = push || "$event" = workflow_dispatch ]] |
| 41 | 45 | |
| @@ -49,8 +53,9 @@ jobs: | ||
| 49 | 53 | --repo "$GH_REPO" \ |
| 50 | 54 | --name "bunny-rollback-$DEPLOYMENT_RUN_ID" \ |
| 51 | 55 | --dir var/deploy-backup |
| 52 | - test -d var/deploy-backup/files | |
| 53 | 56 | test -f var/deploy-backup/missing |
| 57 | + # Artifact downloads omit empty directories on first catalog publication. | |
| 58 | + mkdir -p var/deploy-backup/files | |
| 54 | 59 | |
| 55 | 60 | - name: Restore previous mutable files |
| 56 | 61 | env: |
| @@ -35,7 +35,11 @@ jobs: | |||
| 35 | --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')" | 35 | --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')" |
| 36 | IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata" | 36 | IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata" |
| 37 | test "$status" = completed | 37 | test "$status" = completed |
| 38 | - test "$workflow_path" = ".github/workflows/deploy-bunny.yml" | 38 | + case "$workflow_path" in |
| 39 | + .github/workflows/deploy-bunny.yml) ;; | ||
| 40 | + .github/workflows/rebuild-catalog.yml) test "$event" = workflow_dispatch ;; | ||
| 41 | + *) echo "Unsupported deployment workflow: $workflow_path" >&2; exit 1 ;; | ||
| 42 | + esac | ||
| 39 | test "$head_repository" = "$GH_REPO" | 43 | test "$head_repository" = "$GH_REPO" |
| 40 | [[ "$event" = push || "$event" = workflow_dispatch ]] | 44 | [[ "$event" = push || "$event" = workflow_dispatch ]] |
| 41 | 45 | ||
| @@ -49,8 +53,9 @@ jobs: | |||
| 49 | --repo "$GH_REPO" \ | 53 | --repo "$GH_REPO" \ |
| 50 | --name "bunny-rollback-$DEPLOYMENT_RUN_ID" \ | 54 | --name "bunny-rollback-$DEPLOYMENT_RUN_ID" \ |
| 51 | --dir var/deploy-backup | 55 | --dir var/deploy-backup |
| 52 | - test -d var/deploy-backup/files | ||
| 53 | test -f var/deploy-backup/missing | 56 | test -f var/deploy-backup/missing |
| 57 | + # Artifact downloads omit empty directories on first catalog publication. | ||
| 58 | + mkdir -p var/deploy-backup/files | ||
| 54 | 59 | ||
| 55 | - name: Restore previous mutable files | 60 | - name: Restore previous mutable files |
| 56 | env: | 61 | env: |
modified
CHANGELOG.md +9 -0 | @@ -1,5 +1,14 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 3 | +## [Unreleased] | |
| 4 | + | |
| 5 | +Catalog rebuilds and publication now run independently of settings-page deployment and watch releases. | |
| 6 | + | |
| 7 | +### Changes | |
| 8 | + | |
| 9 | +- ci: extract a manual catalog-only workflow with up to 75 concurrent curl uploads, manifest-last publication, and rollback support. | |
| 10 | +- ci: keep PBW builds, settings-page deployment, and GitHub releases in the release workflow. | |
| 11 | + | |
| 3 | 12 | ## [1.2.0] - 2026-09-20 |
| 4 | 13 | |
| 5 | 14 | The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts. |
| @@ -1,5 +1,14 @@ | |||
| 1 | # Changelog | 1 | # Changelog |
| 2 | 2 | ||
| 3 | +## [Unreleased] | ||
| 4 | + | ||
| 5 | +Catalog rebuilds and publication now run independently of settings-page deployment and watch releases. | ||
| 6 | + | ||
| 7 | +### Changes | ||
| 8 | + | ||
| 9 | +- ci: extract a manual catalog-only workflow with up to 75 concurrent curl uploads, manifest-last publication, and rollback support. | ||
| 10 | +- ci: keep PBW builds, settings-page deployment, and GitHub releases in the release workflow. | ||
| 11 | + | ||
| 3 | ## [1.2.0] - 2026-09-20 | 12 | ## [1.2.0] - 2026-09-20 |
| 4 | 13 | ||
| 5 | The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts. | 14 | The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts. |
modified
README.md +4 -0 | @@ -110,6 +110,10 @@ npm run build:config-site # assemble var/config-site; f | ||
| 110 | 110 | |
| 111 | 111 | `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output. |
| 112 | 112 | |
| 113 | +To rebuild the hosted catalog independently, run **Rebuild and publish IDFM catalog** from GitHub Actions, using `.github/workflows/rebuild-catalog.yml`. It uses the existing `production` environment's IDFM and Bunny credentials and publishes only catalog files, with up to 75 concurrent curl uploads. The manifest is uploaded last. It does not build the PBW, deploy the settings page, or create a GitHub release. | |
| 114 | + | |
| 115 | +Uploads are skipped when the hosted IDFM source revision is unchanged. Enable `force_catalog_upload` when republishing a catalog format change from the same source data. The workflow retains the previous manifest for rollback and shares the production publication lock with page deployments and rollbacks. Release tags continue through `deploy-bunny.yml`, without rebuilding the catalog. | |
| 116 | + | |
| 113 | 117 | ## Contributing |
| 114 | 118 | |
| 115 | 119 | Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue. |
| @@ -110,6 +110,10 @@ npm run build:config-site # assemble var/config-site; f | |||
| 110 | 110 | ||
| 111 | `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output. | 111 | `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output. |
| 112 | 112 | ||
| 113 | +To rebuild the hosted catalog independently, run **Rebuild and publish IDFM catalog** from GitHub Actions, using `.github/workflows/rebuild-catalog.yml`. It uses the existing `production` environment's IDFM and Bunny credentials and publishes only catalog files, with up to 75 concurrent curl uploads. The manifest is uploaded last. It does not build the PBW, deploy the settings page, or create a GitHub release. | ||
| 114 | + | ||
| 115 | +Uploads are skipped when the hosted IDFM source revision is unchanged. Enable `force_catalog_upload` when republishing a catalog format change from the same source data. The workflow retains the previous manifest for rollback and shares the production publication lock with page deployments and rollbacks. Release tags continue through `deploy-bunny.yml`, without rebuilding the catalog. | ||
| 116 | + | ||
| 113 | ## Contributing | 117 | ## Contributing |
| 114 | 118 | ||
| 115 | Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue. | 119 | Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue. |
modified
scripts/verify-config-site.mjs +20 -11 | @@ -58,13 +58,15 @@ function parseJson(path, label) { | ||
| 58 | 58 | } |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | -function verifyLocalSite(sitePath, includeCatalog) { | |
| 61 | +function verifyLocalSite(sitePath, includeCatalog, catalogOnly) { | |
| 62 | 62 | const root = resolve(sitePath); |
| 63 | 63 | if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) { |
| 64 | 64 | fail(`site directory does not exist: ${root}`); |
| 65 | 65 | } |
| 66 | - const indexPath = join(root, "index.html"); | |
| 67 | - if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing"); | |
| 66 | + if (!catalogOnly) { | |
| 67 | + const indexPath = join(root, "index.html"); | |
| 68 | + if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing"); | |
| 69 | + } | |
| 68 | 70 | if (!includeCatalog) { |
| 69 | 71 | return { |
| 70 | 72 | root, |
| @@ -74,12 +76,13 @@ function verifyLocalSite(sitePath, includeCatalog) { | ||
| 74 | 76 | }; |
| 75 | 77 | } |
| 76 | 78 | |
| 77 | - const manifestPath = join(root, "catalog", "manifest.json"); | |
| 79 | + const catalogRoot = catalogOnly ? root : join(root, "catalog"); | |
| 80 | + const manifestPath = join(catalogRoot, "manifest.json"); | |
| 78 | 81 | if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing"); |
| 79 | 82 | |
| 80 | 83 | const manifest = parseJson(manifestPath, "catalog manifest"); |
| 81 | 84 | if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract"); |
| 82 | - const revisionRoot = join(root, "catalog", manifest.revision); | |
| 85 | + const revisionRoot = join(catalogRoot, manifest.revision); | |
| 83 | 86 | if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) { |
| 84 | 87 | fail(`catalog revision directory is missing: ${manifest.revision}`); |
| 85 | 88 | } |
| @@ -187,10 +190,13 @@ export async function verifyConfigSite({ | ||
| 187 | 190 | origin, |
| 188 | 191 | fetcher = globalThis.fetch, |
| 189 | 192 | includeCatalog = true, |
| 193 | + catalogOnly = false, | |
| 190 | 194 | } = {}) { |
| 191 | - const local = verifyLocalSite(sitePath, includeCatalog); | |
| 195 | + if (catalogOnly && !includeCatalog) fail("catalog-only and page-only modes are mutually exclusive"); | |
| 196 | + const local = verifyLocalSite(sitePath, includeCatalog, catalogOnly); | |
| 192 | 197 | const parsedOrigin = siteOrigin(origin); |
| 193 | - await verifyServedFiles(local, parsedOrigin, fetcher); | |
| 198 | + const servedOrigin = catalogOnly ? new URL("catalog/", parsedOrigin) : parsedOrigin; | |
| 199 | + await verifyServedFiles(local, servedOrigin, fetcher); | |
| 194 | 200 | return { |
| 195 | 201 | origin: parsedOrigin.href, |
| 196 | 202 | revision: local.manifest?.revision ?? null, |
| @@ -204,14 +210,17 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { | ||
| 204 | 210 | try { |
| 205 | 211 | const arguments_ = process.argv.slice(2); |
| 206 | 212 | const pageOnly = arguments_.includes("--page-only"); |
| 207 | - const positional = arguments_.filter((argument) => argument !== "--page-only"); | |
| 208 | - if (positional.length > 2 || arguments_.length !== positional.length + (pageOnly ? 1 : 0)) { | |
| 209 | - fail("usage: verify-config-site.mjs [site-path] [origin] [--page-only]"); | |
| 213 | + const catalogOnly = arguments_.includes("--catalog-only"); | |
| 214 | + const positional = arguments_.filter((argument) => argument !== "--page-only" && argument !== "--catalog-only"); | |
| 215 | + if (positional.length > 2 | |
| 216 | + || arguments_.length !== positional.length + (pageOnly ? 1 : 0) + (catalogOnly ? 1 : 0)) { | |
| 217 | + fail("usage: verify-config-site.mjs [site-path] [origin] [--page-only | --catalog-only]"); | |
| 210 | 218 | } |
| 211 | 219 | const result = await verifyConfigSite({ |
| 212 | - sitePath: positional[0] ?? DEFAULT_SITE_PATH, | |
| 220 | + sitePath: positional[0] ?? (catalogOnly ? join(PROJECT_ROOT, "var/catalog/static") : DEFAULT_SITE_PATH), | |
| 213 | 221 | origin: positional[1] ?? process.env.CONFIG_SITE_ORIGIN, |
| 214 | 222 | includeCatalog: !pageOnly, |
| 223 | + catalogOnly, | |
| 215 | 224 | }); |
| 216 | 225 | console.log(JSON.stringify(result, null, 2)); |
| 217 | 226 | } catch (error) { |
| @@ -58,13 +58,15 @@ function parseJson(path, label) { | |||
| 58 | } | 58 | } |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | -function verifyLocalSite(sitePath, includeCatalog) { | 61 | +function verifyLocalSite(sitePath, includeCatalog, catalogOnly) { |
| 62 | const root = resolve(sitePath); | 62 | const root = resolve(sitePath); |
| 63 | if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) { | 63 | if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) { |
| 64 | fail(`site directory does not exist: ${root}`); | 64 | fail(`site directory does not exist: ${root}`); |
| 65 | } | 65 | } |
| 66 | - const indexPath = join(root, "index.html"); | 66 | + if (!catalogOnly) { |
| 67 | - if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing"); | 67 | + const indexPath = join(root, "index.html"); |
| 68 | + if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing"); | ||
| 69 | + } | ||
| 68 | if (!includeCatalog) { | 70 | if (!includeCatalog) { |
| 69 | return { | 71 | return { |
| 70 | root, | 72 | root, |
| @@ -74,12 +76,13 @@ function verifyLocalSite(sitePath, includeCatalog) { | |||
| 74 | }; | 76 | }; |
| 75 | } | 77 | } |
| 76 | 78 | ||
| 77 | - const manifestPath = join(root, "catalog", "manifest.json"); | 79 | + const catalogRoot = catalogOnly ? root : join(root, "catalog"); |
| 80 | + const manifestPath = join(catalogRoot, "manifest.json"); | ||
| 78 | if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing"); | 81 | if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing"); |
| 79 | 82 | ||
| 80 | const manifest = parseJson(manifestPath, "catalog manifest"); | 83 | const manifest = parseJson(manifestPath, "catalog manifest"); |
| 81 | if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract"); | 84 | if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract"); |
| 82 | - const revisionRoot = join(root, "catalog", manifest.revision); | 85 | + const revisionRoot = join(catalogRoot, manifest.revision); |
| 83 | if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) { | 86 | if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) { |
| 84 | fail(`catalog revision directory is missing: ${manifest.revision}`); | 87 | fail(`catalog revision directory is missing: ${manifest.revision}`); |
| 85 | } | 88 | } |
| @@ -187,10 +190,13 @@ export async function verifyConfigSite({ | |||
| 187 | origin, | 190 | origin, |
| 188 | fetcher = globalThis.fetch, | 191 | fetcher = globalThis.fetch, |
| 189 | includeCatalog = true, | 192 | includeCatalog = true, |
| 193 | + catalogOnly = false, | ||
| 190 | } = {}) { | 194 | } = {}) { |
| 191 | - const local = verifyLocalSite(sitePath, includeCatalog); | 195 | + if (catalogOnly && !includeCatalog) fail("catalog-only and page-only modes are mutually exclusive"); |
| 196 | + const local = verifyLocalSite(sitePath, includeCatalog, catalogOnly); | ||
| 192 | const parsedOrigin = siteOrigin(origin); | 197 | const parsedOrigin = siteOrigin(origin); |
| 193 | - await verifyServedFiles(local, parsedOrigin, fetcher); | 198 | + const servedOrigin = catalogOnly ? new URL("catalog/", parsedOrigin) : parsedOrigin; |
| 199 | + await verifyServedFiles(local, servedOrigin, fetcher); | ||
| 194 | return { | 200 | return { |
| 195 | origin: parsedOrigin.href, | 201 | origin: parsedOrigin.href, |
| 196 | revision: local.manifest?.revision ?? null, | 202 | revision: local.manifest?.revision ?? null, |
| @@ -204,14 +210,17 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { | |||
| 204 | try { | 210 | try { |
| 205 | const arguments_ = process.argv.slice(2); | 211 | const arguments_ = process.argv.slice(2); |
| 206 | const pageOnly = arguments_.includes("--page-only"); | 212 | const pageOnly = arguments_.includes("--page-only"); |
| 207 | - const positional = arguments_.filter((argument) => argument !== "--page-only"); | 213 | + const catalogOnly = arguments_.includes("--catalog-only"); |
| 208 | - if (positional.length > 2 || arguments_.length !== positional.length + (pageOnly ? 1 : 0)) { | 214 | + const positional = arguments_.filter((argument) => argument !== "--page-only" && argument !== "--catalog-only"); |
| 209 | - fail("usage: verify-config-site.mjs [site-path] [origin] [--page-only]"); | 215 | + if (positional.length > 2 |
| 216 | + || arguments_.length !== positional.length + (pageOnly ? 1 : 0) + (catalogOnly ? 1 : 0)) { | ||
| 217 | + fail("usage: verify-config-site.mjs [site-path] [origin] [--page-only | --catalog-only]"); | ||
| 210 | } | 218 | } |
| 211 | const result = await verifyConfigSite({ | 219 | const result = await verifyConfigSite({ |
| 212 | - sitePath: positional[0] ?? DEFAULT_SITE_PATH, | 220 | + sitePath: positional[0] ?? (catalogOnly ? join(PROJECT_ROOT, "var/catalog/static") : DEFAULT_SITE_PATH), |
| 213 | origin: positional[1] ?? process.env.CONFIG_SITE_ORIGIN, | 221 | origin: positional[1] ?? process.env.CONFIG_SITE_ORIGIN, |
| 214 | includeCatalog: !pageOnly, | 222 | includeCatalog: !pageOnly, |
| 223 | + catalogOnly, | ||
| 215 | }); | 224 | }); |
| 216 | console.log(JSON.stringify(result, null, 2)); | 225 | console.log(JSON.stringify(result, null, 2)); |
| 217 | } catch (error) { | 226 | } catch (error) { |