From c69017885861edd50dde08cc91905fa63a5cf822 Mon Sep 17 00:00:00 2001 From: Michael Seele Date: Thu, 21 May 2026 15:48:41 +0000 Subject: [PATCH] feat: add per-file cache_rules and fix argument escaping - Add cache_rules input: JSON array of per-file cache overrides, each uploaded individually with its own Cache-Control and Content-Type headers and excluded from the bulk sync - When versioning is true and cache_rules is empty, auto short-cache index.html (no-cache, max-age=300) if it exists in dist_dir - Fix EXCLUDE_ARGS and CT_ARG to use bash arrays for correct handling of filenames and content-type values containing spaces --- publish-static-contents/README.md | 29 +++++++++-- publish-static-contents/action.yml | 80 ++++++++++++++++++++++-------- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/publish-static-contents/README.md b/publish-static-contents/README.md index 5d5c6b4..6c51a52 100644 --- a/publish-static-contents/README.md +++ b/publish-static-contents/README.md @@ -9,11 +9,14 @@ Syncs frontend assets to S3 and invalidates a CloudFront distribution. Optionall | `dist_dir` | No | `frontend/dist` | Path to the frontend dist directory | | `s3_bucket_name` | Yes | — | Name of the S3 bucket to sync assets to | | `cloudfront_distribution_ids` | No | `''` | Space-separated list of CloudFront distribution IDs to invalidate | -| `versioning` | No | `false` | When `true`, enables versioned builds. Old versions older than 7 days are deleted, keeping at least the 2 newest | -| `versioning_prefix` | No | `''` | S3 prefix under which versioned builds are stored (e.g. `_static` → `_static/1234567890/`). When omitted, versions are stored at the bucket root (e.g. `1234567890/`) | +| `versioning` | No | `false` | When `true`, enables versioned builds. All assets get `Cache-Control: public, max-age=31536000, immutable`. Old versions older than 7 days are deleted, keeping at least the 2 newest | +| `versioning_prefix` | No | `''` | S3 prefix under which versioned builds are stored (e.g. `_static` → `_static/1234567890/`). Requires `versioning: true` | +| `cache_rules` | No | `[]` | JSON array of per-file cache overrides. Each matched file is uploaded individually with the given headers and excluded from the bulk sync. When `versioning` is `true` and `cache_rules` is empty and `index.html` exists, defaults to short-caching `index.html` | ## Usage +Plain sync: + ```yaml - uses: https://schmalz-git.git.onstackit.cloud/schmalz/shared-actions/publish-static-contents@publish-static-contents-v1 with: @@ -21,7 +24,7 @@ Syncs frontend assets to S3 and invalidates a CloudFront distribution. Optionall cloudfront_distribution_ids: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} ``` -With versioned static assets at the bucket root: +With versioned static assets: ```yaml - uses: https://schmalz-git.git.onstackit.cloud/schmalz/shared-actions/publish-static-contents@publish-static-contents-v1 @@ -42,9 +45,25 @@ With versioned static assets under a prefix: versioning_prefix: _static ``` +With custom per-file cache rules (e.g. for a PWA): + +```yaml +- uses: https://schmalz-git.git.onstackit.cloud/schmalz/shared-actions/publish-static-contents@publish-static-contents-v1 + with: + s3_bucket_name: my-bucket + cloudfront_distribution_ids: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} + versioning: true + cache_rules: | + [ + {"file": "index.html", "cache_control": "no-cache, no-store, must-revalidate", "content_type": "text/html"}, + {"file": "sw.js", "cache_control": "no-cache, no-store, must-revalidate", "content_type": "application/javascript"}, + {"file": "manifest.webmanifest", "cache_control": "no-cache, max-age=300", "content_type": "application/manifest+json"} + ] +``` + ## Notes -- When `versioning` is `true`, all assets are synced with `Cache-Control: public, max-age=31536000, immutable`. -- `index.html` is always synced separately with `Cache-Control: no-cache, max-age=300` so browsers pick up new deployments quickly. +- When `versioning` is `true` and `cache_rules` is empty, `index.html` is automatically short-cached (`no-cache, max-age=300`) if it exists in the dist directory. +- `cache_rules` works independently of `versioning` and can be used for plain syncs too. - Old versioned builds are pruned: any version folder older than 7 days is deleted, with at least the 2 newest versions always retained. - CloudFront invalidation is skipped when `cloudfront_distribution_ids` is empty. diff --git a/publish-static-contents/action.yml b/publish-static-contents/action.yml index 4eadf6b..9e42cac 100644 --- a/publish-static-contents/action.yml +++ b/publish-static-contents/action.yml @@ -14,13 +14,35 @@ inputs: required: false default: '' versioning: - description: 'When set to true, enables versioned builds. Old versions older than 7 days are deleted, keeping at least the 2 newest.' + description: > + When set to true, enables versioned builds: assets are synced with + "public, max-age=31536000, immutable" and old versions older than 7 days + are deleted, keeping at least the 2 newest. required: false - default: false + default: 'false' versioning_prefix: - description: 'S3 prefix under which versioned builds are stored (e.g. "_static" → _static/1234567890/). When omitted, versions are stored at the bucket root (e.g. 1234567890/).' + description: > + S3 prefix under which versioned builds are stored + (e.g. "_static" → _static/1234567890/). When omitted, versions are + stored at the bucket root. Requires versioning to be true. required: false default: '' + cache_rules: + description: > + JSON array of per-file cache overrides. Each matched file is uploaded + individually with the given headers and excluded from the bulk sync. + content_type is optional. Applied independently of versioning. + When versioning is enabled and cache_rules is empty, defaults to + short-caching index.html — the standard SPA behaviour. + Example: + cache_rules: | + [ + {"file": "index.html", "cache_control": "no-cache, no-store, must-revalidate", "content_type": "text/html"}, + {"file": "sw.js", "cache_control": "no-cache, no-store, must-revalidate", "content_type": "application/javascript"}, + {"file": "manifest.webmanifest", "cache_control": "no-cache, max-age=300", "content_type": "application/manifest+json"} + ] + required: false + default: '[]' runs: using: composite @@ -42,29 +64,45 @@ runs: INPUT_S3_BUCKET_NAME: ${{ inputs.s3_bucket_name }} INPUT_VERSIONING: ${{ inputs.versioning }} INPUT_VERSIONING_PREFIX: ${{ inputs.versioning_prefix }} + INPUT_CACHE_RULES: ${{ inputs.cache_rules }} run: | + # When versioning is active and no cache_rules provided, short-cache index.html by default + EFFECTIVE_CACHE_RULES="${INPUT_CACHE_RULES}" + if [[ "${INPUT_VERSIONING}" == "true" && "${EFFECTIVE_CACHE_RULES}" == "[]" && -f "${INPUT_DIST_DIR}/index.html" ]]; then + EFFECTIVE_CACHE_RULES='[{"file": "index.html", "cache_control": "no-cache, max-age=300", "content_type": "text/html"}]' + fi + + # Upload each file from cache_rules with its own headers, + # and collect --exclude args so the bulk sync skips them + EXCLUDE_ARGS=() + if [[ "${EFFECTIVE_CACHE_RULES}" != "[]" ]]; then + while IFS= read -r rule; do + file=$(echo "$rule" | jq -r '.file') + cache_control=$(echo "$rule" | jq -r '.cache_control') + content_type=$(echo "$rule" | jq -r '.content_type // empty') + + EXCLUDE_ARGS+=("--exclude" "$file") + + if [[ -f "${INPUT_DIST_DIR}/${file}" ]]; then + CT_ARG=() + if [[ -n "$content_type" ]]; then + CT_ARG=("--content-type" "$content_type") + fi + aws s3 cp "${INPUT_DIST_DIR}/${file}" "s3://${INPUT_S3_BUCKET_NAME}/${file}" \ + --cache-control "$cache_control" \ + --metadata-directive REPLACE \ + "${CT_ARG[@]}" + fi + done < <(echo "$EFFECTIVE_CACHE_RULES" | jq -c '.[]') + fi + + # Bulk sync remaining files; versioned builds get immutable cache headers CACHE_CONTROL_ARG="" if [[ "${INPUT_VERSIONING}" == "true" ]]; then CACHE_CONTROL_ARG="--cache-control 'public, max-age=31536000, immutable'" fi - EXCLUDE_INDEX_ARG="" - if [[ "${INPUT_VERSIONING}" == "true" && -f "${INPUT_DIST_DIR}/index.html" ]]; then - EXCLUDE_INDEX_ARG="--exclude index.html" - fi - aws s3 sync "${INPUT_DIST_DIR}" "s3://${INPUT_S3_BUCKET_NAME}" $CACHE_CONTROL_ARG $EXCLUDE_INDEX_ARG - - name: Publish index.html without immutable cache - if: ${{ inputs.versioning == 'true' }} - shell: bash - env: - INPUT_DIST_DIR: ${{ inputs.dist_dir }} - INPUT_S3_BUCKET_NAME: ${{ inputs.s3_bucket_name }} - run: | - if [[ -f "${INPUT_DIST_DIR}/index.html" ]]; then - aws s3 cp "${INPUT_DIST_DIR}/index.html" "s3://${INPUT_S3_BUCKET_NAME}/index.html" \ - --cache-control "no-cache, max-age=300" \ - --metadata-directive REPLACE - fi + aws s3 sync "${INPUT_DIST_DIR}" "s3://${INPUT_S3_BUCKET_NAME}" $CACHE_CONTROL_ARG "${EXCLUDE_ARGS[@]}" - name: Clean up old versioned static builds if: ${{ inputs.versioning == 'true' }} @@ -77,7 +115,7 @@ runs: if [[ -n "${INPUT_VERSIONING_PREFIX}" ]]; then S3_PATH="s3://$INPUT_S3_BUCKET_NAME/$INPUT_VERSIONING_PREFIX" fi - + aws s3 ls "${S3_PATH}/" \ | grep -oP '(?<=PRE )[0-9]+' \ | sort --stable --reverse \ -- 2.49.1